本教程上接 Tutorial 4。 我们已经建立一个网页投票应用,现在我们将为它创建一些自动化测试。
引入自动测试
什么是自动测试?
测试是检查你的代码能否正常运行的简单程序.
测试可以划分为不同的级别。一些测试可能适用于一个细微的细节(某个特定的模型方法是否按期望返回值?),而其他则检查软件的整体运作(在站点上的一系列用户输入是否产生所需的结果?)。这与以前在Tutorial 2中使用shell
之前的测试类型没有区别,以检查方法的行为,或运行应用程序并输入数据来检查它的行为。
自动化测试的不同之处就在于这些测试会由系统来帮你完成。 你创建了一组测试程序,当你修改了你的应用,你就可以用这组测试程序来检查你的代码是否仍然同预期的那样运行,而无需执行耗时的手动测试。
为什么需要创建测试
那么,为什么要创建测试?而且为什么是现在?
你可能感觉学习Python/Django已经足够,再去学习其他的东西也许需要付出巨大的努力而且没有必要。 毕竟,我们的投票程序现在运行正常,创建自动化测试吃力不讨好。 如果你学习Django就是为了创建一个投票应用,那么创建自动化测试显然没有必要。 但如果不是这样,现在是一个很好的学习机会。
测试将节省您的时间
在某种程度上, ‘确认它似乎在正常运行’是一种令人满意的事情。 在更复杂的应用中,你可能有几十种组件之间的复杂的相互作用。
这些组件的任何一个小的变化,都可能对应用的行为产生意想不到的影响。 检查起来‘似乎正常工作’可能意味着你需要运用二十种不同的测试数据来测试你代码的功能,而这仅仅是为了确保你没有搞砸某些事 —— 这会浪费很多时间。
尤其是当自动化测试只需要数秒就可以完成以上的任务时。 如果出现了错误,测试程序还能够帮助找出引发这个异常行为的代码。
有时候你可能会觉得编写测试程序将你从有价值的、创造性的编程工作里带出,带到了单调乏味、无趣的编写测试中,尤其是当你的代码工作正常时。
然而,比起用几个小时的时间来手动测试你的程序,或者试图找出代码中一个新引入的问题的原因,编写测试程序还是令人惬意的。
测试不仅仅是识别问题,而且还阻止了它们
将测试看做只是开发过程中消极的一面是错误的。
没有测试,应用的目的和意图将会变得相当模糊。 甚至在你查看自己的代码时,也不会发现这些代码真正干了些什么。
测试改变了他们从内部点亮你的代码,当出现问题时,即使你甚至没有意识到错误的,他们会将注意力集中在出现错误的部分上。
测试使您的代码更具吸引力
您可能已经创建了一个辉煌的软件,但您会发现,许多其他开发人员将拒绝查看它,因为它缺乏测试;没有测试,他们不会相信它。 Jacob Kaplan-Moss,Django最初的几个开发者之一,说过“不具有测试程序的代码是设计上的错误。”
你需要开始编写测试的另一个原因就是其他的开发者在他们认真研读你的代码前可能想要查看一下它有没有测试。
测试帮助团队一起工作
之前的观点是从单个开发人员来维护一个程序这个方向来阐述的。 复杂的应用将会被一个团队来维护。 测试能够减少同事在无意间破坏你的代码的机会(和你在不知情的情况下破坏别人的代码的机会)。 如果你想在团队中做一个好的Django开发者,你必须擅长测试!
基本测试策略
编写测试有很多种方法。
一些程序员遵循一个名为“test-driven development(测试驱动开发)”的学科;实际上他们在编写代码之前就写了他们的测试。 这似乎与直觉不符,尽管这种方法与大多数人经常的做法很相似:人们先描述一个问题,然后创建一些代码来解决这个问题。 测试驱动开发可以用Python测试用例将这个问题简单地形式化。
更常见的情况是,刚接触测试的人会先编写一些代码,然后才决定为这些代码创建一些测试。 也许在之前就编写一些测试会好一点,但什么时候开始都不算晚。
有时候很难解决从什么地方开始编写测试。 如果你已经编写了数千行Python代码,挑出一些功能去测试是很困难的。 这种情况下,在下次你对代码进行变更,或者添加一个新功能或者修复一个bug时,编写你的第一个测试,效果会非常好。
现在,让我们马上来编写一个测试。
写我们的第一个测试
定义一个BUG
幸运的是,polls应用中有一个小错误让我们可以马上来修复它:Question.was_published_recently() 方法返回True如果 Question 在最近一天发布,(这是对的),但是如果Question的polls 字段是在未来,它还返回True(这肯定是不对的)。
要检查该bug是否真的存在,使用Admin创建一个日期在将来的Question,并使用 shell
检查方法:
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # create a Question instance with pub_date 30 days in the future
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # was it published recently?
>>> future_question.was_published_recently()
True
由于将来的事情并不能称之为‘最近’,这确实是一个错误。
创建测试来暴露错误
What we’ve just done in the shell
to test for the problem is exactly what we can do in an automated test, so let’s turn that into an automated test.
A conventional place for an application’s tests is in the application’s tests.py
file; the testing system will automatically find tests in any file whose name begins with test
.
Put the following in the tests.py
file in the polls
application:
我们需要在自动化测试里做的和刚才在 shell
里做的差不多,让我们来将它转换成一个自动化测试。
应用程序测试通常放在应用程序的tests.py
文件中;测试系统会自动在任何以test
开头的文件中找到测试。
将下面的代码放入polls
应用下的tests.py
文件中:
polls/tests.py
import datetime
from django.test import TestCase
from django.utils import timezone
from .models import Question
class QuestionModelTests(TestCase):
def test_was_published_recently_with_future_question(self):
"""
当 pub_date 值为未来的时间时 was_published_recently() 应该返回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.
运行测试
在终端中,我们可以运行我们的测试:
$ python manage.py test polls
你将看到类似下面的输出:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
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
创建一个pub_date
为未来30天的Question实例.并使用
assertIs()
方法,发现它的was_published_recently()返回True,尽管我们希望它返回False
这个测试通知我们哪个测试失败,甚至是错误出现在哪一行。
修正错误
我们已经知道问题是什么:如果它的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'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
在找出一个错误之后,我们编写一个测试来暴露这个错误,然后在代码中更正这个错误让我们的测试通过。
未来,我们的应用可能会出许多其它的错误,但是我们可以保证我们不会无意中再次引入这个错误,因为简单地运行一下这个测试就会立即提醒我们。 我们可以认为这个应用的这一小部分会永远安全了。
更全面的测试
当我们在这里,我们可以进一步定位was_published_recently()方法;事实上,如果我们在修复一个bug时又导致另外一个bug的话,这将是非常尴尬的。
在同一个类中添加两个其它的测试方法,来更加综合地测试这个方法:
polls/tests.py
def test_was_published_recently_with_old_question(self):
"""
was_published_recently() returns False for questions whose pub_date
is older than 1 day.
"""
time = timezone.now() - datetime.timedelta(days=1, seconds=1)
old_question = Question(pub_date=time)
self.assertIs(old_question.was_published_recently(), False)
def test_was_published_recently_with_recent_question(self):
"""
was_published_recently() returns True for questions whose pub_date
is within the last day.
"""
time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
recent_question = Question(pub_date=time)
self.assertIs(recent_question.was_published_recently(), True)
现在我们有三个测试来保证无论发布时间是在过去、现在还是未来 Question.was_published_recently()
都将返回合理的数据。
再说一次,polls 应用虽然简单,但是无论它今后会变得多么复杂以及会和多少其它的应用产生相互作用,我们都能保证我们刚刚为它编写过测试的那个方法会按照预期的那样工作。
测试一个视图
这个投票应用没有区分能力:它将会发布任何一个Question,包括 pub_date字段位于未来。 我们应该改进这一点。 设定pub_date在未来应该表示Question在未来发布,但是直到那个时间点才会变得可见。
视图测试
当我们修复上面的错误时,我们首先编写测试,然后编写代码来修复它。 实际上这是测试驱动开发的一个简单的例子,但是我们做的这个工作并不重要。
在我们的第一个测试中,我们专注于代码内部的行为。 在这个测试中,我们想要通过浏览器从用户的角度来检查它的行为。
在我们试着修复任何事情之前,让我们先查看一下我们能用到的工具。
Django测试客户端
Django提供了一个测试Client
来模拟用户和代码的交互。 我们可以在tests.py
甚至在shell
中使用它。
我们将再次以shell
开始,但是我们需要做很多在tests.py
中不必做的事。 首先是在 shell
中设置测试环境:`
>>> from django.test.utils import setup_test_environment
>>> 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()
这些都做完之后,我们可以让这个客户端来为我们做一些事:
>>> # 从 '/' (url)得到一个response
>>> response = client.get('/')
Not Found: /
>>> # 我们预期应该返回一个404错误信息; 如果你看到的反而是"Invalid HTTP_HOST header" 错误和返回400信息,
>>> # 你可能漏掉了调用 setup_test_environment() .
>>> response.status_code
404
>>> # 另一方面,我们应该期待在 '/polls/' 路径下能够返回一些东西。我们将使用 'reverse()' 方法而不使用固定的URL
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n <ul>\n \n <li><a href="/polls/1/">What's up?</a></li>\n \n </ul>\n\n'
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>
改善我们的视图
投票的列表显示还没有发布的投票(即pub_date在未来的投票)。让我们来修复它。
在 Tutorial 4 中,我们介绍了一个继承 ListView
的基于类的视图:
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]
We need to amend the get_queryset() method and change it so that it also checks the date by comparing it with timezone.now(). First we need to add an import:
我们需要修改get_queryset()
方法并进行更改,以便通过将其与timezone.now()进行比较来检查日期。 首先我们需要添加一行导入:
polls/views.py
from django.utils import timezone
然后我们必须像这样修改get_queryset方法:
polls/views.py
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查询集.
测试我们的新视图
启动服务器、在浏览器中载入站点、创建一些发布时间在过去和将来的Questions
,然后检验只有已经发布的Question会展示出来,现在你可以对自己感到满意了。 你不想每次修改可能与这相关的代码时都重复这样做 —— 所以让我们基于以上shell
会话中的内容,再编写一个测试。
将下面的代码添加到polls/tests.py
:
from django.urls import reverse
我们将创建一个快捷函数来创建Question,同时我们要创建一个新的测试类:
polls/tests.py
def create_question(question_text, days):
"""
传入question_text,以及days来创建一个question对象,这个days表示距离现在发布的天数(可以是未来也可以是过去)
"""
time = timezone.now() + datetime.timedelta(days=days)
return Question.objects.create(question_text=question_text, pub_date=time)
class QuestionIndexViewTests(TestCase):
def test_no_questions(self):
"""
如果没有任何question存在,会显示一个小提示
"""
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_past_question(self):
"""
pub_date是过去的Questions将展示在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_future_question(self):
"""
pub_date是未来的Questions将不展示在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_future_question_and_past_question(self):
"""
当pub_date为未来与过去的Question同时存在,仅展示过去
"""
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_two_past_questions(self):
"""
展示多个questions
"""
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_no_questions
不会产生任何问题,但是会检查该消息:“没有polls可用”,并验证latest_question_list
是否为空。 注意django.test.TestCase
类提供一些额外的断言方法。 在这些例子中,我们使用 assertContains()
和 assertQuerysetEqual()
.
在test_past_question
中,我们创建一个pub_date为过去的Question,并确认它出现在列表中。
在test_future_question
中,我们创建一个pub_date为未来的Question,数据库会为每一个测试方法进行重置,所以第一个Question已经不在那里,因此首页面里不应该有任何Question。
事实上,我们是在用测试模拟站点上的管理员输入和用户体验,检查针对系统每一个状态和状态的新变化,发布的是否是预期的结果。
测试DetailView
What we have works well; however, even though future questions don’t appear in the index, users can still reach them if they know or guess the right URL. So we need to add a similar constraint to DetailView:
我们已经做的很不错了; 然而,即使 future questions 没有出现在index中,如果用户知道或猜测了正确的URL,用户仍然可以访问它们。 所以我们需要给DetailView添加一个这样的约束:
polls/views.py
class DetailView(generic.DetailView):
...
def get_queryset(self):
"""
排除那些还没有发布的questions
"""
return Question.objects.filter(pub_date__lte=timezone.now())
当然,我们将增加一些测试来检验pub_date 在过去的Question 可以显示出来,而Question在未来的不可以:
polls/tests.py
class QuestionDetailViewTests(TestCase):
def test_future_question(self):
"""
question在未来的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_past_question(self):
"""
展示question在过去的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)
更多测试的想法
我们应该添加一个类似ResultsView的方法到get_queryset并为该视图创建一个新的测试类。 这与我们刚刚创造的非常相似;其实会有很多重复。
我们还可以在其它方面改进我们的应用,并随之不断增加测试。 例如,发布一个没有Choices的Questions就显得很傻*。 所以,我们的视图应该检查这点并排除这些 Questions。 我们的测试应该创建一个不带Choices 的 Question然后测试它不会发布出来, 同时创建一个类似的带有 Choices的Question 并验证它会 发布出来。
也许登录的用户应该被允许查看还没发布的 Questions,但普通游客不行。 再说一次:无论添加什么代码来完成这个要求,需要提供相应的测试代码,无论你是先编写测试然后让这些代码通过测试,还是先用代码解决其中的逻辑然后编写测试来证明它。
从某种程度上来说,你一定会查看你的测试,然后想知道是否你的测试程序过于臃肿,这将我们带向下面的内容:
测试时,越多越好
看起来我们的测试代码的增长正在失去控制。 以这样的速度,测试的代码量将很快超过我们的应用,对比我们其它优美简洁的代码,重复毫无美感。
没关系。 让它们继续增长。 最重要的是,你可以写一个测试一次,然后忘了它。 当你继续开发你的程序时,它将继续执行有用的功能。
有时,测试需要更新。 假设我们修改我们的视图使得只有具有Choices的 Questions 才会发布。 在这种情况下,我们许多已经存在的测试都将失败 —— 这会告诉我们哪些测试需要被修改来使得它们保持最新,所以从某种程度上讲,测试可以自己照顾自己。
在最坏的情况下,在你的开发过程中,你会发现许多测试现在变得冗余。 即使这不是问题;在测试冗余是一个好的事情。
只要你的测试被合理地组织,它们就不会变得难以管理。 从经验上来说,好的做法是:
每个模型或视图具有一个单独的
TestClass
为你想测试的每一种情况建立一个单独的测试方法
测试方法的名字可以描述它们的功能
进一步测试
本教程只介绍了一些基本的测试。 还有很多你可以做,有许多非常有用的工具可以随便使用来你实现一些非常聪明的做法。
例如,虽然我们的测试覆盖了模型的内部逻辑和视图发布信息的方式,但你还可以使用一个“浏览器”框架例如Selenium来测试你的HTML文件在浏览器中真实渲染的样子。 这些工具不仅可以让你检查你的Django代码的行为,还能够检查你的JavaScript的行为。 它会启动一个浏览器,并开始与你的网站进行交互,就像有一个人在操纵一样,非常值得一看! Django 包含一个LiveServerTestCase
来帮助与Selenium 这样的工具集成。
如果你有一个复杂的应用,你可能为了实现continuous integration,想在每次提交代码后对代码进行自动化测试,让代码自动 —— 至少是部分自动 —— 地来控制它的质量。
发现你应用中未经测试的代码的一个好方法是检查测试代码的覆盖率。 这也有助于识别脆弱的甚至死代码。 如果你不能测试一段代码,这通常意味着这些代码需要被重构或者移除。 Coverage将帮助我们识别死代码。 查看Integration with coverage.py 来了解更多细节。
Testing in Django有关于测试更加全面的信息。
接下来是什么?
关于测试的完整细节,请查看Testing in Django.
当你对Django 视图的测试感到满意后,请阅读part 6 of this tutorial来了解静态文件的管理。