编写你的第一个 Django 应用程序,第5部分
本教程从教程 4 停止的地方开始。我们已经构建了一个网络投票应用程序,现在我们将为其创建一些自动化测试。
一、自动化测试简介
1.什么是自动化测试?
测试是检查代码操作的例程。
测试在不同级别运行。一些测试可能适用于微小的细节(特定的模型方法是否按预期返回值?),而其他测试则检查软件的整体操作(网站上的一系列用户输入是否产生了所需的结果?)。这与前面在教程 2 中执行的测试类型没有什么不同,使用 shell
检查方法的行为,或运行应用程序并输入数据以检查其行为方式。
自动化测试的不同之处在于,测试工作是由系统为您完成的。创建一组测试一次,然后在对应用进行更改时,可以检查代码是否仍按原始预期工作,而无需执行耗时的手动测试。
2.为什么需要创建测试?
你可能会觉得你已经足够多了学习Python/Django,还有另一件事要学习和做,这似乎让人不知所措,也许是不必要的。毕竟,我们的民意调查应用程序现在运行得非常愉快;经历创建自动化测试的麻烦不会让它工作得更好。如果创建民意调查应用程序是你将要做的 Django 编程的最后一点,那么确实,你不需要知道如何创建自动化测试。但是,如果不是这样,现在是学习的绝佳时机。
测试将节省您的时间。
在某种程度上,“检查它似乎有效”将是一个令人满意的测试。在更复杂的应用程序中,组件之间可能有数十种复杂的交互。
任何这些组件的更改都可能对应用程序的行为产生意想不到的后果。检查它是否仍然“似乎有效”可能意味着使用二十种不同的测试数据变体运行代码的功能,以确保您没有破坏某些东西 - 这不是一个很好的利用你的时间。
当自动化测试可以在几秒钟内为您完成此操作时,尤其如此。如果出现问题,测试还将帮助识别导致意外行为的代码。
有时,将自己从富有成效、创造性的编程工作中抽离出来,面对编写测试的平淡无奇和乏味的业务,这似乎是一件苦差事,尤其是当您知道自己的代码工作正常时。
但是,编写测试的任务比花费数小时手动测试应用程序或尝试确定新引入的问题的原因要充实得多。
3.测试不仅可以识别问题,还可以防止问题
将测试仅仅视为开发的消极方面是错误的。
如果没有测试,应用程序的目的或预期行为可能相当不透明。即使它是你自己的代码,你有时也会发现自己在其中四处寻找,试图找出它到底在做什么。
测试改变了这一点;它们从内部点亮你的代码,当出现问题时,它们会把注意力集中在出错的部分——即使你甚至没有意识到它已经出错了。
4.测试使您的代码更具吸引力
您可能已经创建了一个出色的软件,但您会发现许多其他开发人员会拒绝查看它,因为它缺乏测试;没有测试,他们就不会信任它。Django的原始开发人员之一Jacob Kaplan-Moss说:“没有测试的代码在设计上是被破坏的。
其他开发人员希望在认真对待您的软件之前看到您的软件中的测试,这是您开始编写测试的另一个原因。
5.测试帮助团队协同工作
前面几点是从维护应用程序的单个开发人员的角度编写的。复杂的应用程序将由团队维护。测试保证同事不会无意中破坏你的代码(并且你不会在不知情的情况下破坏他们的代码)。如果你想以 Django 程序员为生,你必须擅长编写测试!
二、基本测试策略
有很多方法可以进行写作测试。
一些程序员遵循一门名为“ 测试驱动开发 ”;他们实际上在编写代码之前编写测试。这似乎有悖常理,但实际上它类似于大多数人经常做的事情:他们描述一个问题,然后创建一些代码来解决它。测试驱动开发在 Python 测试用例中形式化问题。
更常见的是,测试新手会创建一些代码,然后决定应该有一些测试。也许早点写一些测试会更好,但现在开始永远不会太晚。
有时很难弄清楚从哪里开始编写测试。如果你已经写了几千行Python,选择测试一些东西可能并不容易。
在这种情况下,在下次进行更改时编写第一个测试是富有成效的,无论是在添加新功能还是修复错误时。
因此,让我们立即执行此操作。
三、编写我们的第一个测试
1. 我们识别了一个错误。
幸运的是, polls
应用程序中有一个小错误需要我们立即修复:如果 Question
是在最后一天发布的(这是正确的),但如果 Question
的 pub_date
字段是将来的(当然不是), Question.was_published_recently()
方法将返回 True
。
通过使用 shell
检查日期为将来的问题的方法来确认错误:
$ python 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
由于未来的事情不是“最近的”,这显然是错误的。
2.创建一个测试来暴露错误
我们刚刚在 shell
中为测试问题所做的正是我们在自动测试中可以做的事情,因此让我们将其转换为自动测试。
应用程序测试的常规位置是应用程序的 tests.py
文件;测试系统将自动在名称以 test
开头的任何文件中查找测试。
将以下内容放在 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
子类,该方法在将来创建一个带有 pub_date
的 Question
实例。
然后我们检查 was_published_recently()
的输出 - 它应该是 False。
3. 运行测试
在终端中,我们可以运行我们的测试:
$ 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'...
发生的事情是这样的:
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
测试会通知我们哪个测试失败,甚至通知发生故障的行。
4. 修复错误
我们已经知道问题是什么:如果将来有 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'...
确定错误后,我们编写了一个测试来公开它并更正代码中的错误,以便我们的测试通过。
将来我们的应用程序可能会出现许多其他问题,但我们可以确定我们不会无意中重新引入此错误,因为运行测试会立即警告我们。
我们可以认为应用程序的这一小部分永远安全地固定下来。
5. 更全面的测试
当我们在这里时,我们可以进一步确定 was_published_recently()
方法;事实上,如果在修复一个错误时我们引入了另一个错误,那将是非常尴尬的。
向同一类再添加两个测试方法,以更全面地测试方法的行为:
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
是一个最小的应用程序,但无论它在未来变得多么复杂,以及它与任何其他代码交互,我们现在都可以保证我们为其编写测试的方法将以预期的方式运行。
6. 测试视图
民意调查应用程序相当无差别:它将发布任何问题,包括将来 pub_date
字段所在的问题。我们应该改善这一点。将来设置 pub_date
应该意味着问题在那一刻发布,但在此之前不可见。
7. 视图测试
当我们修复上面的错误时,我们首先编写了测试,然后编写了修复它的代码。事实上,这是测试驱动开发的一个例子,但我们以什么顺序完成工作并不重要。
在我们的第一个测试中,我们密切关注代码的内部行为。对于此测试,我们希望检查其行为,就像用户通过 Web 浏览器体验的那样。
在我们尝试修复任何问题之前,让我们看一下我们可以使用的工具。
8. Django 测试客户端
Django 提供了一个测试 Client
来模拟用户在视图级别与代码交互。我们可以在 tests.py
甚至 shell
中使用它。
我们将从 shell
,我们需要做一些在 tests.py
中不需要的事情。首先是在 shell
中设置测试环境:
$ python 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?>]>
9. 改进我们的视图
投票列表显示尚未发布的投票(即将来有 pub_date
的投票)。让我们解决这个问题。
在教程 4 中,我们引入了基于 ListView
的基于类的视图:
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
然后我们必须像这样修改get_queryset
方法:
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())
返回包含 Question
的查询集,其 pub_date
小于或等于 - 即早于或等于 - timezone.now
。
10. 测试我们的新视图
现在,您可以通过启动 runserver
,在浏览器中加载站点,使用过去和将来的日期创建 Questions
,并检查是否仅列出已发布的日期,从而使自己的行为符合预期。
您不希望每次进行可能影响此操作的任何更改时都必须这样做 - 因此,让我们也根据上面的 shell
会话创建一个测试。
将以下内容添加到 polls/tests.py
:
我们将创建一个快捷方式函数来创建问题以及一个新的测试类:
from django.urls import reverse
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
的问题。数据库会针对每个测试方法重置,因此第一个问题不再存在,因此索引中不应包含任何问题。
等等。实际上,我们正在使用这些测试来讲述站点上的管理员输入和用户体验的故事,并检查在每个状态和系统状态的每个新更改中,是否发布了预期结果。
11. 测试 DetailView
我们所拥有的运作良好;但是,即使将来的问题不会出现在索引中,如果用户知道或猜到了正确的网址,他们仍然可以联系到这些问题。
所以我们需要向 DetailView
添加一个类似的约束: