一、编写测试程序
1.1 发现bug
很巧,在我们的投票应用中有一个小bug需要修改:在 Question.was_published_recently()
方法的返回值中, 当 Question 在最近的一天发布的时候返回True(这是正确的),然而当 Question 在未来的日期内发布的时候也返回 True(这是错误的)。
要检查该bug是否真的存在,使用Admin创建一个未来的日期,并使用 shell 检查:
# python manage.py shell
In [1]: import datetime
In [2]: from django.utils import timezone
In [3]: from polls.models import Question
# 创建一个发布日期在30天后的问卷
In [4]: future_question = Question(pub_date=timezone.now()+datetime.timedelta(days=30))
# 测试一下返回值
In [5]: future_question.was_published_recently()
Out[5]: True
问题的核心在于我们允许创建在未来时间才发布的问卷,由于“未来”不等于“最近”,因此这显然是个bug。
1.2 创建一个测试来暴露这个bug
刚才我们是在 shell 中测试了这个bug,那如何通过自动化测试来发现这个bug呢?
通常,我们会把测试代码放在应用的tests.py
文件中;测试系统将自动地从任何名字以 test 开头的文件中查找测试程序。每个app在创建的时候,都会自动创建一个tests.py
文件,就像views.py
等文件一样。
将下面的代码输入投票应用的polls/tests.py
文件中:
import datetime
from django.utils import timezone
from django.test import TestCase
from .models import Question
class QuestionMethodTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
在将来发布的问卷应该返回False
"""
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertIs(future_question.was_published_recently(), False)
我们在这里创建了一个 django.test.TestCase
的子类,它具有一个方法,该方法创建一个 pub_date
在未来的 Question 实例。最后我们检查 was_published_recently()
的输出,它应该是 False。
1.3 运行测试程序
在终端中,运行下面的命令,
python manage.py test polls
你将看到结果如下:
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_question
self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
这背后的过程:
python manage.py test polls
命令会查找所有 polls 应用中的测试程序- 发现一个
django.test.TestCase
的子类 - 为测试创建了一个特定的数据库
- 查找函数名以
test
开头的测试方法 - 在
test_was_published_recently_with_future_question
方法中,创建一个 Question 实例, 该实例的 pub_data 字段的值是30天后的未来日期 - 然后利用
assertIs()
方法,它发现was_published_recently()
返回了 True,而不是我们希望的 False
这个测试通知我们哪个测试失败了,错误出现在哪一行。整个测试用例基本上和Python内置的unittest非常相似。
1.4 修复bug
现在我们已经知道问题是什么:如果它的 pub_date 是在未来,Question.was_published_recently()
应该返回 False。 在 models.py 中修复这个方法,让它只有当日期是在过去时才返回 True:
polls/models.py
def was_published_recently(self):
now = timezone.now()
return now - datetime.timedelta(days=1) <= self.pub_date <= now
再次运行测试:
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
可以看到bug已经没有了。
1.5 更全面的测试
我们可以使 was_published_recently()
方法更加可靠,事实上, 在修复一个错误的同时又引入一个新的错误将是一件很令人尴尬的事。下面,我们在同一个测试类中再额外添加两个其它的方法, 来更加全面地进行测试:
polls/tests.py
def test_was_published_recently_with_old_question(self):
"""
日期超过1天的将返回False。这里创建了一个30天前发布的实例。
"""
time = timezone.now() - datetime.timedelta(days=30)
old_question = Question(pub_date=time)
self.assertIs(old_question.was_published_recently(), False)
def test_was_published_recently_with_recent_question(self):
"""
最近一天内的将返回True。这里创建了一个1小时内发布的实例。
"""
time = timezone.now() - datetime.timedelta(hours=1)
recent_question = Question(pub_date=time)
self.assertIs(recent_question.was_published_recently(), True)
现在我们有三个测试来保证无论发布时间是在过去、现在还是未来 Question.was_published_recently()
都将返回正确的结果。最后,polls 应用虽然简单,但是无论它今后会变得多么复杂以及会和多少其它的应用产生相互作用, 我们都能保证 Question.was_published_recently()
会按照预期的那样工作。
二、测试视图
这个投票应用没有辨别能力:它将会发布任何的 Question ,包括 pub_date 字段是未来的。我们应该改进这一点。 让 pub_date 是将来时间的 Question 应该在未来发布,但是一直不可见,直到那个时间点才会变得可见。
2.1 什么是视图测试
当我们修复上面的错误时,我们先写测试,然后修改代码来修复它。
事实上,这是测试驱动开发的一个简单的例子,但做的顺序并不真的重要。在我们的第一个测试中,我们专注于代码内部的行为。 在这个测试中,我们想要通过浏览器从用户的角度来检查它的行为。在我们试着修复任何事情之前, 让我们先查看一下我们能用到的工具。
2.2 Django的测试客户端
Django提供了一个 Client 用来模拟用户和代码的交互。我们可以在 tests.py 甚至 shell 中使用它。
先介绍使用 shell 的情况,这种方式下,需要做很多在 tests.py 中不必做的事。首先是设置测试环境:
# python manage.py shell
In [1]:from django.test.utils import setup_test_environment
In [2]:setup_test_environment()
setup_test_environment()
会安装一个模板渲染器, 它使我们可以检查一些额外的属性比如 response.context
,这些属性通常情况下是访问不到的。请注意, 这种方法不会建立一个测试数据库,所以以下命令将运行在现有的数据库上, 输出的内容也会根据你已经创建的Question的不同而稍有不同。
如果你当前settings.py
中的的 TIME_ZONE 不正确,那么你或许得不到预期的结果。在进行下一步之前, 请确保时区设置正确。
下面我们需要导入测试客户端类(在之后的tests.py
中,我们将使用 django.test.TestCase
类, 它具有自己的客户端,不需要导入这个类):
>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()
下面是具体的一些使用操作:
# get a response from '/'
In [5]: response = client.get('/')
# 这个地址应该返回的是404页面
In [6]: response.status_code
Out[6]: 404
# 另一方面我们希望在'/polls/'获取一些内容
# 通过使用'reverse()'方法,而不是URL硬编码
In [7]: from django.urls import reverse
In [8]: response = client.get(reverse('polls:index'))
In [9]: response.status_code
Out[9]:200
In [10]: response.content
Out[10]: '<!--<!DOCTYPE html>-->\n<!--<html lang="en">-->\n<!--<head>-->\n <!--<meta charset="UTF-8">-->\n <!--<title>Title</title>-->\n<!--</head>-->\n<!--<body>-->\n\n<!--</body>-->\n<!--</html>-->\n\n <ul>\n \n <li><a href="/polls/2/">What's new?</a></li>\n \n <li><a href="/polls/1/">What's up?</a></li>\n \n </ul>\n'
In [11]: response.context['latest_question_list']
Out[11]: <QuerySet [<Question: What's new?>, <Question: What's up?>]>
2.3 改进视图
投票的列表会显示还没有发布的问卷(即 pub_date 在未来的问卷)。
polls/views.py 原代码
class IndexView(generic.ListView):
template_name = 'polls/index.html'
context_object_name = 'latest_question_list'
def get_queryset(self):
"""Return the last five published questions."""
return Question.objects.order_by('-pub_date')[:5]
我们需要在 get_queryset() 方法中对比 timezone.now() 。首先导入 timezone 模块,然后修改 get_queryset() 方法,更改如下:
polls/views.py
from django.utils import timezone
def get_queryset(self):
"""
Return the last five published questions (not including those set to be
published in the future).
"""
return Question.objects.filter(
pub_date__lte=timezone.now()
).order_by('-pub_date')[:5]
Question.objects.filter(pub_date__lte=timezone.now())
返回一个查询集, 包含 pub_date
小于等于 timezone.now
的Question。
2.4 测试新视图
现在,您可以通过启动运行服务器,在浏览器中加载站点,创建过去和将来的日期的问题,并检查仅列出已发布的站点, 从而满足您的需求。如果你不想每次修改可能与这相关的代码时都重复这样做———所以我们还要根据上面的shell会话创建一个测试。
将下面的代码添加到 polls/tests.py:
polls/tests.py
from django.urls import reverse
def create_question(question_text, days):
"""
2个参数,一个是问卷的文本内容,另外一个是当前时间的偏移天数,
负值表示发布日期在过去,正值表示发布日期在将来。
"""
time = timezone.now() + datetime.timedelta(days=days)
return Question.objects.create(question_text=question_text, pub_date=time)
class QuestionViewTests(TestCase):
def test_index_view_with_no_questions(self):
"""
如果问卷不存在,给出相应的提示。
"""
response = self.client.get(reverse('polls:index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No polls are available.")
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_index_view_with_a_past_question(self):
"""
发布日期在过去的问卷将在index页面显示。
"""
create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question.>']
)
def test_index_view_with_a_future_question(self):
"""
发布日期在将来的问卷不会在index页面显示
"""
create_question(question_text="Future question.", days=30)
response = self.client.get(reverse('polls:index'))
self.assertContains(response, "No polls are available.")
self.assertQuerysetEqual(response.context['latest_question_list'], [])
def test_index_view_with_future_question_and_past_question(self):
"""
即使同时存在过去和将来的问卷,也只有过去的问卷会被显示。
"""
create_question(question_text="Past question.", days=-30)
create_question(question_text="Future question.", days=30)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question.>']
)
def test_index_view_with_two_past_questions(self):
"""
index页面可以同时显示多个问卷。
"""
create_question(question_text="Past question 1.", days=-30)
create_question(question_text="Past question 2.", days=-5)
response = self.client.get(reverse('polls:index'))
self.assertQuerysetEqual(
response.context['latest_question_list'],
['<Question: Past question 2.>', '<Question: Past question 1.>']
)
让我们更详细地看下以上这些内容。
第一个是Question的快捷函数 create_question
,功能是将创建Question的过程封装起来。
test_index_view_with_no_questions
不创建任何Question,但会检查消息“No polls are available.”
并验证 latest_question_list
为空。注意 django.test.TestCase
类提供一些额外的断言方法。在这些例子中, 我们使用了:meth:~django.test.SimpleTestCase.assertContains()
和 assertQuerysetEqual()
在 test_index_view_with_a_past_question
中,我们创建一个Question并验证它是否出现在列表中。
在 test_index_view_with_a_future_question
中,我们创建一个 pub_date 在未来的Question。 数据库会为每一个测试方法进行重置,所以第一个Question已经不在那里,因此index页面里不应该有任何Question。
诸如此类,事实上,我们是在用测试,模拟站点上的管理员输入和用户体验,检查系统的每一个状态变化,发布的是预期的结果。
2.5 测试DetailView
然而,即使未来发布的Question不会出现在index中,如果用户知道或者猜出正确的URL依然可以访问它们。 所以我们需要给 DetailView 视图添加一个这样的约束:
polls/views.py
class DetailView(generic.DetailView):
...
def get_queryset(self):
"""
Excludes any questions that aren't published yet.
"""
return Question.objects.filter(pub_date__lte=timezone.now())
同样,我们将增加一些测试来检验 pub_date 在过去的Question可以显示出来,而 pub_date 在未来的不可以
polls/tests.py
class QuestionIndexDetailTests(TestCase):
def test_detail_view_with_a_future_question(self):
"""
访问发布时间在将来的detail页面将返回404.
"""
future_question = create_question(question_text='Future question.', days=5)
url = reverse('polls:detail', args=(future_question.id,))
response = self.client.get(url)
self.assertEqual(response.status_code, 404)
def test_detail_view_with_a_past_question(self):
"""
访问发布时间在过去的detail页面将返回详细问卷内容
"""
past_question = create_question(question_text='Past Question.', days=-5)
url = reverse('polls:detail', args=(past_question.id,))
response = self.client.get(url)
self.assertContains(response, past_question.question_text)