编写你的第一个 Django 应用,第 5 部分¶
本教程从教程 4结束的地方开始。我们已经构建了一个网络投票应用,现在我们将为它创建一些自动化测试。
在哪里获取帮助
如果您在学习本教程时遇到问题,请访问常见问题解答的获取帮助部分。
介绍自动化测试¶
什么是自动化测试?¶
测试是检查代码操作的例程。
测试在不同的级别上进行。一些测试可能适用于一个微小的细节(某个特定的模型方法是否按预期返回值?),而另一些测试则检查软件的整体操作(网站上的一系列用户输入是否产生期望的结果?)。这与您之前在教程 2中使用shell
检查方法的行为或运行应用程序并输入数据以检查其行为所做的测试类型没有区别。
自动化测试的不同之处在于,测试工作由系统为您完成。您只需创建一组测试,然后在对您的应用进行更改时,您可以检查您的代码是否仍按您的预期工作,而无需执行耗时的手动测试。
为什么要创建测试¶
那么为什么要创建测试,以及为什么现在要创建?
您可能会觉得仅仅学习 Python/Django 就已经足够了,还要学习和做另一件事可能会让人不知所措,并且可能是不必要的。毕竟,我们的投票应用现在运行得很好;费尽心思创建自动化测试并不会让它运行得更好。如果创建投票应用是您将要做的最后一点 Django 编程,那么确实,您不需要知道如何创建自动化测试。但是,如果不是这种情况,现在是一个学习的好时机。
测试将节省您的时间¶
在某种程度上,“检查它似乎有效”将是一个令人满意的测试。在一个更复杂的应用程序中,组件之间可能存在数十种复杂的交互。
任何一个组件的更改都可能对应用程序的行为产生意想不到的后果。检查它是否仍然“似乎有效”可能意味着使用测试数据的二十种不同变体运行代码的功能,以确保您没有破坏任何东西——这不是您时间的有效利用。
当自动化测试可以在几秒钟内为您完成此操作时,尤其如此。如果出现问题,测试还有助于识别导致意外行为的代码。
有时,您可能觉得让自己从富有成效的创造性编程工作中抽身出来,去面对编写测试这种乏味而无趣的事情是一件苦差事,尤其是在您知道自己的代码工作正常的情况下。
但是,编写测试的任务比手动测试应用程序或试图找出新引入问题的起因要令人满意得多。
测试不仅识别问题,还防止问题¶
认为测试仅仅是开发的负面方面是一个错误。
没有测试,应用程序的目的或预期行为可能会相当模糊。即使是您自己的代码,您有时也会发现自己四处查看,试图找出它的确切作用。
测试改变了这一点;它们从内部照亮您的代码,当出现问题时,它们会将光线集中到出现问题的部分——即使您还没有意识到它出了问题。
测试使您的代码更具吸引力¶
您可能创建了一个很棒的软件,但您会发现许多其他开发人员会拒绝查看它,因为它缺少测试;如果没有测试,他们就不会信任它。Django 的最初开发者之一 Jacob Kaplan-Moss 说:“没有测试的代码本质上是有缺陷的。”
其他开发人员希望在认真对待您的软件之前看到其中的测试,这是您开始编写测试的另一个原因。
测试有助于团队协作¶
前面几点是从维护应用程序的单个开发人员的角度编写的。复杂的应用程序将由团队维护。测试保证同事不会无意中破坏您的代码(并且您不会在不知情的情况下破坏他们的代码)。如果您想以 Django 程序员为生,那么您必须擅长编写测试!
基本测试策略¶
有许多方法可以编写测试。
一些程序员遵循一种称为“测试驱动开发”的纪律;他们实际上是在编写代码之前编写测试。这可能看起来违反直觉,但实际上它类似于大多数人通常会做的事情:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发将问题形式化到 Python 测试用例中。
更常见的情况是,测试新手会创建一些代码,然后决定它应该有一些测试。也许在早期编写一些测试会更好,但现在开始也为时不晚。
有时很难弄清楚从哪里开始编写测试。如果您已经编写了几千行 Python 代码,那么选择要测试的内容可能并不容易。在这种情况下,在下次进行更改时(无论是在添加新功能还是修复错误时)编写第一个测试是富有成效的。
所以让我们立即这样做。
编写我们的第一个测试¶
我们识别出一个错误¶
幸运的是,polls
应用程序中有一个小错误需要我们立即修复:Question.was_published_recently()
方法如果Question
是在过去一天内发布的,则返回True
(这是正确的),但如果Question
的pub_date
字段在将来,也会返回True
(当然不正确)。
使用shell
检查将来日期的问题的方法来确认错误
$ python manage.py shell
...\> py manage.py 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
由于未来发生的事情不是“最近的”,因此这显然是错误的。
创建一个测试来暴露错误¶
我们刚刚在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):
"""
was_published_recently() returns False for questions whose pub_date
is in the future.
"""
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
实例,其pub_date
在将来。然后我们检查was_published_recently()
的输出——应该是 False。
运行测试¶
在终端中,我们可以运行我们的测试
$ python manage.py test polls
...\> py 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/djangotutorial/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
,则您可能错过了第 2 部分中的一个步骤,在该步骤中,我们将datetime
和timezone
的导入添加到polls/models.py
中。复制该部分中的导入,然后尝试再次运行测试。
发生的事情是这样的
manage.py test polls
在polls
应用程序中查找测试它找到一个
django.test.TestCase
类的子类。它为测试目的创建了一个特殊的数据库。
它查找测试方法 - 那些名称以
test
开头的。在
test_was_published_recently_with_future_question
中,它创建了一个Question
实例,其pub_date
字段在未来30天。……并使用
assertIs()
方法,它发现其was_published_recently()
返回True
,尽管我们希望它返回False
。
测试告诉我们哪个测试失败,甚至失败发生的行。
修复错误¶
我们已经知道问题是什么:Question.was_published_recently()
如果其pub_date
在未来,则应返回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()
方法;事实上,如果在修复一个错误时引入了另一个错误,那将是令人尴尬的。
向同一个类添加另外两个测试方法,以更全面地测试该方法的行为。
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
是一个最小的应用程序,但无论它在未来变得多么复杂,以及与之交互的其他代码是什么,我们现在有一些保证,即我们编写的测试方法将以预期的方式运行。
测试视图¶
polls应用程序相当不加区分:它将发布任何问题,包括那些pub_date
字段在将来的问题。我们应该改进这一点。将pub_date
设置为未来意味着问题将在那一刻发布,但在那之前是不可见的。
视图的测试¶
当我们修复上面的错误时,我们首先编写了测试,然后编写了修复它的代码。事实上,这是一个测试驱动开发的例子,但我们以什么顺序进行工作并不重要。
在我们的第一个测试中,我们密切关注代码的内部行为。对于此测试,我们希望检查其行为,就像用户通过网络浏览器体验的那样。
在我们尝试修复任何内容之前,让我们看看我们可用的工具。
Django测试客户端¶
Django提供了一个测试Client
来模拟用户在视图级别与代码交互。我们可以在tests.py
中甚至在shell
中使用它。
我们将再次从shell
开始,在这里我们需要做一些在tests.py
中不需要做的事情。首先是在shell
中设置测试环境。
$ python manage.py shell
...\> py manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
setup_test_environment()
安装了一个模板渲染器,它将允许我们检查响应的一些其他属性,例如response.context
,否则这些属性将不可用。请注意,此方法不会设置测试数据库,因此以下操作将针对现有数据库运行,输出可能略有不同,具体取决于您已经创建了哪些问题。如果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 '/'
>>> response = client.get("/")
Not Found: /
>>> # we should expect a 404 from that address; if you instead see an
>>> # "Invalid HTTP_HOST header" error and a 400 response, you probably
>>> # omitted the setup_test_environment() call described earlier.
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # 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
在将来的投票)。让我们修复它。
在教程 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]
我们需要修改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())
返回一个包含Question
的查询集,其pub_date
小于或等于 - 即早于或等于 - timezone.now
。
测试我们的新视图¶
现在,您可以通过启动runserver
、在浏览器中加载站点、创建Questions
(日期在过去和未来)并检查只有已发布的问题才会列出,从而确认其按预期运行。您不希望每次进行可能影响此操作的任何更改时都必须这样做 - 因此,让我们也创建一个基于上述shell
会话的测试。
将以下内容添加到polls/tests.py
中。
polls/tests.py
¶from django.urls import reverse
我们将创建一个快捷函数来创建问题以及一个新的测试类。
polls/tests.py
¶def create_question(question_text, days):
"""
Create a question with the given `question_text` and published the
given number of `days` offset to now (negative for questions published
in the past, positive for questions that have yet to be published).
"""
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):
"""
If no questions exist, an appropriate message is displayed.
"""
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):
"""
Questions with a pub_date in the past are displayed on the
index page.
"""
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):
"""
Questions with a pub_date in the future aren't displayed on
the index page.
"""
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):
"""
Even if both past and future questions exist, only past questions
are displayed.
"""
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):
"""
The questions index page may display multiple questions.
"""
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
不创建任何问题,但检查消息:“没有可用的投票。”并验证latest_question_list
为空。请注意,django.test.TestCase
类提供了一些额外的断言方法。在这些示例中,我们使用assertContains()
和assertQuerySetEqual()
。
在test_past_question
中,我们创建了一个问题并验证它是否出现在列表中。
在test_future_question
中,我们创建了一个pub_date
在将来的问题。数据库为每个测试方法重置,因此第一个问题不再存在,因此索引中也不应该有任何问题。
等等。实际上,我们正在使用测试来讲述网站上的管理员输入和用户体验的故事,并检查在系统的每个状态和每个新状态更改时,是否发布了预期结果。
测试DetailView
¶
我们现有的代码运行良好;但是,即使未来的问题不会出现在索引中,用户仍然可以通过知道或猜测正确的 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())
然后,我们应该添加一些测试,以检查Question
的pub_date
在过去是否可以显示,以及pub_date
在未来是否不会显示。
polls/tests.py
¶class QuestionDetailViewTests(TestCase):
def test_future_question(self):
"""
The detail view of a question with a pub_date in the future
returns a 404 not found.
"""
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):
"""
The detail view of a question with a pub_date in the past
displays the question's text.
"""
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 等工具集成。
如果您有一个复杂的应用程序,您可能希望在每次提交时自动运行测试以进行持续集成,以便至少部分自动化质量控制。
发现应用程序未经测试的部分的一个好方法是检查代码覆盖率。这也有助于识别脆弱甚至失效的代码。如果您无法测试一段代码,通常意味着该代码应该被重构或删除。覆盖率将有助于识别失效的代码。有关详细信息,请参阅与 coverage.py 集成。
Django 中的测试 提供了有关测试的全面信息。
下一步是什么?¶
有关测试的完整详细信息,请参阅Django 中的测试。
当您对测试 Django 视图感到满意后,请阅读本教程的第 6 部分,了解静态文件管理。