简介
本教程从《表单处理与精简代码》结束的地方开始。我们已经构建了一个网络投票应用程序,现在将为其创建一些自动化测试。
本教程从《表单处理与精简代码》结束的地方开始。我们已经构建了一个网络投票应用程序,现在将为其创建一些自动化测试。
测试是用于检查代码运行情况的程序。
测试在不同层面进行。有些测试可能针对极小的细节(“某个特定的模型方法是否按预期返回值?”),而其他测试则检查软件的整体运行情况(“在网站上进行一系列用户输入是否能产生预期结果?”)。这与你在《设置数据库》中早期所做的测试并无不同,当时你使用 shell
来检查方法的行为,或者运行应用程序并输入数据以检查其表现。
自动化测试的不同之处在于,测试工作由系统为你完成。你只需创建一组测试,然后在对应用程序进行更改时,就可以检查代码是否仍按你最初的意图运行,而无需进行耗时的手动测试。
那么,为什么要创建测试,又为什么现在创建呢?
你可能觉得仅仅学习 Python/Django 就已经忙得不可开交了,再去学习和做另一件事可能会让人应接不暇,甚至觉得没有必要。毕竟,我们的投票应用程序目前运行得相当顺利;费劲去创建自动化测试并不会让它运行得更好。如果创建投票应用程序是你做的最后一点 Django 编程工作,那么确实,你无需知道如何创建自动化测试。但是,如果并非如此,那么现在就是学习的绝佳时机。
在一定程度上,“检查它似乎能正常工作”将是一种令人满意的测试。在一个更复杂的应用程序中,组件之间可能会有数十种复杂的交互。
这些组件中的任何一个发生变化都可能对应用程序的行为产生意想不到的后果。检查它是否仍然“似乎能正常工作”可能意味着使用二十种不同的测试数据变体来遍历代码的功能,以确保你没有破坏任何东西——这可不是利用时间的好方法。
当自动化测试能在几秒钟内为你完成这项工作时,情况尤其如此。如果出了问题,测试还将有助于识别导致意外行为的代码。
有时,从富有成效的创造性编程工作中抽身去面对编写测试这种平淡无奇且无趣的事情,可能会让人觉得是件苦差事,尤其是当你知道你的代码运行正常的时候。
然而,编写测试的任务比花几个小时手动测试应用程序或试图找出新出现问题的原因要充实得多。
仅仅将测试视为开发的一个负面方面是错误的。
没有测试,应用程序的目的或预期行为可能相当模糊。即使是你自己的代码,有时你也会发现自己在其中四处摸索,试图弄清楚它到底在做什么。
测试改变了这一点;它们从内部照亮你的代码,当出现问题时,它们会将注意力集中在出错的部分——即使你甚至都没有意识到它出了问题。
你可能创建了一个很棒的软件,但你会发现许多其他开发者会拒绝查看它,因为它没有测试;没有测试,他们就不会信任它。Django 的原始开发者之一雅各布·卡普兰 - 莫斯(Jacob Kaplan - Moss)说:“没有测试的代码从设计上就是有缺陷的。”
其他开发者在认真对待你的软件之前希望看到测试,这也是你开始编写测试的另一个原因。
前面几点是从单个开发者维护应用程序的角度写的。复杂的应用程序将由团队维护。测试可确保同事不会无意中破坏你的代码(并且你也不会在不知情的情况下破坏他们的代码)。如果你想以 Django 程序员为生,你必须擅长编写测试!
编写测试有很多方法。
有些程序员遵循一种叫做“测试驱动开发”(Test-driven development)的原则;他们实际上在编写代码之前就编写测试。这可能看起来有悖常理,但实际上这与大多数人通常会做的事情类似:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发在一个 Python 测试用例中把这个问题形式化。
更常见的情况是,刚开始接触测试的人会先创建一些代码,然后才决定这些代码应该有一些测试。也许早点写一些测试会更好,但开始测试永远都不晚。
有时很难弄清楚从哪里开始编写测试。如果你已经写了几千行 Python 代码,选择要测试的内容可能并不容易。在这种情况下,下次你进行更改时,无论是添加新功能还是修复错误,编写你的第一个测试都是富有成效的。
那么让我们马上开始吧。
幸运的是,“投票”应用程序中有一个小漏洞需要我们立即修复:Question.was_published_recently()
方法在 Question
的发布时间在过去一天内时返回 True
(这是正确的),但当 Question
的 pub_date
字段在未来时也返回 True
(这显然不对)。
通过使用 shell
检查一个发布时间在未来的问题的该方法来确认这个漏洞:
cd ~/project/mysite
python manage.py shell
>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> ## 创建一个发布时间在未来30天的Question实例
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> ## 它是最近发布的吗?
>>> future_question.was_published_recently()
True
由于未来的事情不是“最近”发生的,这显然是错误的。
我们刚才在 shell
中为测试这个问题所做的操作,正是我们在自动化测试中可以做的,所以让我们把它变成一个自动化测试。
应用程序测试的常规位置是在应用程序的 tests.py
文件中;测试系统会自动在任何以 test
开头的文件中找到测试。
在“投票”应用程序的 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):
"""
对于发布时间在未来的问题,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
的子类,其中有一个方法创建了一个发布时间在未来的 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'...
不同的错误?
如果你在这里得到一个 NameError
,你可能在《教程02 - 导入时区》的第2部分中遗漏了一个步骤,我们在那里将 datetime
和 timezone
的导入添加到了 polls/models.py
中。从该部分复制导入内容,然后再次尝试运行你的测试。
发生的情况如下:
manage.py test polls
在“投票”应用程序中查找测试django.test.TestCase
类的一个子类test
开头的方法test_was_published_recently_with_future_question
中,它创建了一个 Question
实例,其 pub_date
字段在未来30天assertIs()
方法,它发现其 was_published_recently()
返回 True
,尽管我们希望它返回 False
测试告诉我们哪个测试失败了,甚至是失败发生的行。
我们已经知道问题所在:如果 Question
的 pub_date
在未来,Question.was_published_recently()
应该返回 False
。在 models.py
中修改该方法,使其仅在日期也在过去时才返回 True
:
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()
方法;实际上,如果在修复一个漏洞时又引入了另一个漏洞,那将是非常尴尬的。
在同一个类中再添加两个测试方法,以更全面地测试该方法的行为:
def test_was_published_recently_with_old_question(self):
"""
对于发布时间早于1天的问题,was_published_recently() 返回 False。
"""
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() 返回 True。
"""
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()
对于过去、最近和未来的问题都返回合理的值。
同样,“投票”是一个最小化的应用程序,但无论它未来变得多么复杂,以及它与其他代码如何交互,我们现在有了一些保证,即我们为其编写测试的方法将按预期方式运行。
投票应用程序相当随意:它会发布任何问题,包括那些 pub_date
字段在未来的问题。我们应该改进这一点。将 pub_date
设置为未来的时间应该意味着该问题在那一刻发布,但在此之前不可见。
当我们修复上述漏洞时,我们先编写了测试,然后编写了修复代码。实际上,这是测试驱动开发的一个例子,但我们按什么顺序进行这项工作并不重要。
在我们的第一个测试中,我们密切关注了代码的内部行为。对于这个测试,我们想检查它在用户通过网页浏览器体验时的行为。
在我们尝试修复任何问题之前,让我们先看看我们可以使用的工具。
Django 提供了一个测试 ~django.test.Client
,用于在视图级别模拟用户与代码的交互。我们可以在 tests.py
中使用它,甚至在 shell
中也可以。
我们将再次从 shell
开始,在那里我们需要做一些在 tests.py
中不需要做的事情。首先是在 shell
中设置测试环境:
python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
~django.test.utils.setup_test_environment
安装了一个模板渲染器,这将允许我们检查响应上的一些额外属性,例如 response.context
,否则这些属性是不可用的。请注意,此方法 不会 设置测试数据库,因此以下操作将针对现有数据库运行,并且输出可能会因你已经创建的问题而略有不同。如果你的 settings.py
中的 TIME_ZONE
不正确,你可能会得到意外的结果。如果你不记得之前设置过它,请在继续之前检查一下。
接下来,我们需要导入测试客户端类(稍后在 tests.py
中,我们将使用 django.test.TestCase
类,它自带了自己的客户端,所以这里不需要):
>>> from django.test import Client
>>> ## 创建一个供我们使用的客户端实例
>>> client = Client()
准备好之后,我们可以让客户端为我们做一些工作:
>>> ## 从'/'获取响应
>>> response = client.get("/")
Not Found: /
>>> ## 我们应该从那个地址得到一个404;如果你看到的是
>>> ## "Invalid HTTP_HOST header" 错误和一个400响应,你可能
>>> ## 省略了前面描述的 setup_test_environment() 调用。
>>> response.status_code
404
>>> ## 另一方面,我们应该期望在'/polls/'找到一些东西
>>> ## 我们将使用'reverse()'而不是硬编码的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
在未来的投票)。让我们修复这个问题。
在《表单处理与精简代码》中,我们引入了一个基于 ~django.views.generic.list.ListView
的类视图:
class IndexView(generic.ListView):
template_name = "polls/index.html"
context_object_name = "latest_question_list"
def get_queryset(self):
"""返回最后五个已发布的问题。"""
return Question.objects.order_by("-pub_date")[:5]
我们需要修改 get_queryset()
方法,并更改它,以便它还通过与 timezone.now()
进行比较来检查日期。首先,我们需要添加一个导入:
from django.utils import timezone
然后我们必须像这样修改 get_queryset
方法:
def get_queryset(self):
"""
返回最后五个已发布的问题(不包括那些设置为在未来发布的问题)。
"""
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
。
现在你可以通过启动 runserver
,在浏览器中加载网站,创建过去和未来日期的 Question
,并检查是否只列出了已发布的问题,来确保它按预期运行。你不想每次进行任何可能影响此功能的更改时都必须这样做——所以让我们也基于上面的 shell
会话创建一个测试。
在 polls/tests.py
中添加以下内容:
from django.urls import reverse
我们还将创建一个快捷函数来创建问题以及一个新的测试类:
def create_question(question_text, days):
"""
使用给定的 `question_text` 创建一个问题,并将其发布时间设置为从现在起偏移给定的 `天数`(过去发布的问题为负数,尚未发布的问题为正数)。
"""
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):
"""
如果没有问题存在,则显示适当的消息。
"""
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):
"""
过去发布时间的问题显示在索引页面上。
"""
question = create_question(question_text="Past question.", days=-30)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[question],
)
def test_future_question(self):
"""
未来发布时间的问题不在索引页面上显示。
"""
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):
"""
即使同时存在过去和未来的问题,也只显示过去的问题。
"""
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],
)
def test_two_past_questions(self):
"""
问题索引页面可能会显示多个问题。
"""
question1 = create_question(question_text="Past question 1.", days=-30)
question2 = create_question(question_text="Past question 2.", days=-5)
response = self.client.get(reverse("polls:index"))
self.assertQuerySetEqual(
response.context["latest_question_list"],
[question2, question1],
)
让我们更仔细地看看其中一些测试。
首先是一个问题快捷函数 create_question
,用于减少创建问题过程中的重复操作。
test_no_questions
不创建任何问题,但检查消息:“No polls are available.” 并验证 latest_question_list
为空。请注意,django.test.TestCase
类提供了一些额外的断言方法。在这些示例中,我们使用了 ~django.test.SimpleTestCase.assertContains()
和 ~django.test.TransactionTestCase.assertQuerySetEqual()
。
在 test_past_question
中,我们创建一个问题并验证它出现在列表中。
在 test_future_question
中,我们创建一个 pub_date
在未来的问题。每个测试方法都会重置数据库,所以第一个问题不再存在,因此索引中也不应该有任何问题。
以此类推。实际上,我们正在使用这些测试来讲述一个关于管理员输入和网站用户体验的故事,并检查在系统的每个状态以及状态的每个新变化下,预期结果是否会显示出来。
DetailView
我们目前所做的工作运行良好;然而,即使未来的问题不在 索引 中显示,但如果用户知道或猜到正确的 URL,他们仍然可以访问它们。所以我们需要在 DetailView
中添加类似的约束:
class DetailView(generic.DetailView):
...
def get_queryset(self):
"""
排除任何尚未发布的问题。
"""
return Question.objects.filter(pub_date__lte=timezone.now())
然后我们应该添加一些测试,以检查 pub_date
在过去的 Question
是否可以显示,而 pub_date
在未来的是否不可以显示:
class QuestionDetailViewTests(TestCase):
def test_future_question(self):
"""
未来发布时间的问题的详细视图返回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):
"""
过去发布时间的问题的详细视图显示问题的文本。
"""
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
的 Question
可以在网站上发布是很愚蠢的。所以,我们的视图可以检查这一点,并排除这样的 Question
。我们的测试将创建一个没有 Choices
的 Question
,然后测试它不会被发布,同时创建一个有 Choices
的类似 Question
,并测试它会被发布。
也许登录的管理员用户应该被允许查看未发布的 Question
,但普通访客不可以。同样:无论为实现此目的需要在软件中添加什么,都应该伴随着一个测试,无论你是先编写测试然后使代码通过测试,还是先在代码中制定逻辑然后编写测试来证明它。
在某个时候,你肯定会看看你的测试,并想知道你的代码是否存在测试臃肿的问题,这就引出了:
看起来我们的测试似乎有些失控了。照这个速度,很快我们测试中的代码就会比应用程序中的代码还多,而且与我们代码其他部分优雅的简洁性相比,这种重复性显得不太美观。
这没关系。让它们增长吧。在很大程度上,你可以编写一次测试,然后就不用管它了。在你继续开发程序时,它会继续发挥其有用的功能。
有时测试需要更新。假设我们修改了视图,使得只有带有 Choices
的 Question
才会被发布。在这种情况下,我们现有的许多测试将会失败—— 准确地告诉我们哪些测试需要修改以跟上最新情况,所以从这个程度上来说,测试会自我维护。
最坏的情况是,在你继续开发时,你可能会发现有些测试现在已经冗余了。即便如此,这也不是个问题;在测试中,冗余是 一件好事。
只要你的测试安排合理,它们就不会变得难以管理。好的经验法则包括:
TestClass
本教程仅介绍了测试的一些基础知识。你还可以做很多其他事情,并且有许多非常有用的工具可供你使用,以实现一些非常巧妙的功能。
例如,虽然我们这里的测试涵盖了模型的一些内部逻辑以及视图发布信息的方式,但你可以使用诸如 Selenium 这样的“浏览器内”框架来测试你的HTML在浏览器中的实际渲染方式。这些工具不仅可以让你检查Django代码的行为,还可以检查例如你的JavaScript的行为。看到测试启动一个浏览器,并开始与你的网站进行交互,就好像是一个人在操作它一样,这是非常了不起的!Django包含 ~django.test.LiveServerTestCase
以方便与Selenium等工具集成。
如果你有一个复杂的应用程序,你可能希望为了 持续集成 的目的,在每次提交时自动运行测试,以便质量控制本身——至少部分——实现自动化。
发现应用程序中未测试部分的一个好方法是检查代码覆盖率。这也有助于识别脆弱甚至无用的代码。如果你无法测试一段代码,通常意味着这段代码应该被重构或删除。代码覆盖率将有助于识别无用代码。有关详细信息,请参阅 topics-testing-code-coverage
。
《Django 中的测试 </topics/testing/index>》包含有关测试的全面信息。
恭喜你!你已经完成了“创建一些自动化测试”实验。你可以在LabEx中练习更多实验来提升你的技能。