编写您的第一个Django应用, 第5部分

本教程上接 教程 4。我们已经开发了网页投票应用,现在 为它创建一些自动化测试。

自动化测试介绍

什么是自动化测试?

测试是检查代码操作的简单例行程序。

测试分为不同的级别。有些测试可能用于某个细节(比如特定的模型方法是否返回预期的值?), 而其他测试是检查软件的整体操作(比如站点上一系列用户的输入是否产生期望的结果?)。 这和 教程 2 中的测试是无异的,使用 shell 来检查方法的行为,或者运行应用程序并输入数据来检查它的行为。

自动化 测试的不同之处在于测试工作是由系统帮您完成的。您创建一组测试,然后在对应用进行更改时, 您可以检查代码是否仍然按照原始的方式工作,而无需执行耗时的手动测试。

为什么需要自动化测试

那么为什么创建测试,为什么是现在?

您可能感觉学习 Python/Django 已经足够了,再去学习其他的东西也许需要付出巨大的努力而且没有必要, 毕竟我们的投票应用已经愉快地运行起来了。与其花时间去做自动化测试还不如改进现在的应用。如果 学习 Django 就是仅仅是为了创建一个小小投票应用,那么涉足自动化测试显然没有必要。但如果不是这样, 现在是一个很好的学习机会。

测试可以节约时间

某种程度上,“检查能工作”似乎是种比较满意的测试结果。但在一些复杂的应用中,您会发现组件之间 存在各种各样复杂的交互关系。

这些组件有任何小的的更改都有可能会对应用程序的行为产生意想不到的后果。要检查它仍“能工作”, 可能意味着您需要使用二十种不同的测试数据来测试您的代码,而这仅仅是为了确保您没有做错某些事 —— 这不值得您花时间。

而自动化测试只需要数秒就可以完成以上的任务。如果出现了错误,还能够帮助找出引发这个异常行为的 代码。

有时候您可能会觉得编写测试程序相比起有价值的、创造性的编程工作显得单调乏味、无趣, 尤其是当您的代码工作正常时。

但是,比起用几个小时的时间来手动测试您的程序,或者试图找出代码中一个新生问题的原因, 编写自动化测试程序的性价比还是很高的。

测试可以发现并防止问题

将测试看做只是开发中消极的一面是错误的。

没有测试,应用程序的目的或预期行为可能是相当不透明的。即使这是您自己的代码,您也会发现 自己都不知道它在做什么。

测试可以改变这一情况;它们使您的代码内部变得明晰,当错误出现后,它们会明确地指出哪部分 代码出了问题 —— 甚至您自己都没意识到出现了问题

测试使您的代码更受欢迎

您可能已经创建了一个堪称辉煌的软件,但是您会发现许多其他的开发者会由于它缺少测试程序而 拒绝查看它;没有测试程序,他们不会信任它。Django最初的几个开发者之一 Jacob Kaplan-Moss 说过:“没有测试的代码是设计上的错误”。

您需要开始编写测试的另一个原因就是其他的开发者在他们认真研读您的代码前可能想要查看一下 它有没有测试。

测试有助于团队合作

之前的观点是从单个开发人员来维护一个程序这个方向来阐述的。复杂的应用将会被一个团队来维护。 测试能够减少同事在无意间破坏您的代码的情况(以及您在不知情的情况下破坏别人的代码的情况)。 如果您想作为Django程序员生活,您必须擅于编写测试!

基本的测试策略

编写测试程序有很多种方法。

一些程序员遵循一种叫做“测试驱动开发”的规则;他们在编写代码前会先写好测试。这看起来似乎 有点反人类,但实际上它与大多数人经常的做法很相似:先描述一个问题,然后编写代码来解决它。 测试驱动开发可以简单地在 Python 测试用例中将问题格式化。

很多时候,刚接触测试的人会先编写一些代码,再编写一些测试。事实上,更早编写一些测试会好一点, 但不管怎么说什么时候开始都不算晚。

有时候您很难决定从什么时候开始编写测试。如果您已经编写了数千行 Python 代码,挑选它们中的 一些来进行测试是不太容易的。这种情况下,在下次您对代码进行变更,添加一个新功能或者修复一个 bug 时,编写您的第一个测试,效果会非常好。下面,

让我们马上行动吧。

编写我们的第一个测试

发现bug

很巧,在我们的 投票 应用中有一个小 bug 需要修改:当 Question 在最近的一天发布时, Question.was_published_recently() 返回True(这是正确的),然而当 Questionpub_date 字段是未来的日期时也返回True(这是错误的)。

要检查该 bug 是否真的存在,使用 Admin 创建一个未来的日期,并使用 shell 检查:

>>> import datetime
>>> from django.utils import timezone
>>> from polls.models import Question
>>> # 创建一个pub_date在30天之后的Question实例
>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
>>> # 它最近发布了吗?
>>> future_question.was_published_recently()
True

由于“将来”不等于“最近”,这显然是错的。

创建一个测试来暴露bug

我们在 shell 中测试 bug 所做的也能在自动化测试中做到,让我们将之变为自动化测试吧。

通常,我们会把测试代码放在应用的 tests.py 文件中;测试系统将自动地从任何名字以 test 开头的文件中查找测试。

将下面的代码放入 投票 应用的 tests.py 文件中:

polls/tests.py
import datetime

from django.utils import timezone
from django.test import TestCase

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

您将看到如下结果:

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 中,创建一个 Question 实例,其 pub_date 字段的值是30天后的未来日期
  • ... 然后使用 assertIs() 方法,它发现 was_published_recently() 返回 True, 而不是我们希望的 False

这个测试通知我们哪个测试失败了,以及错误出现在哪一行。

修复 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'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...

在找出一个 bug 之后,我们编写了一个测试来暴露它并更正了这个错误,从而让我们的测试通过。

未来,在应用中可能会出许多其它未知的错误,但是我们可以保证不会无意中再次引入这个错误, 因为简单地运行一下这个测试就会立即提醒我们。 我们可以认为这个应用的这一小部分会永远安全了。

更全面的测试

我们可以使 was_published_recently() 方法更加可靠。事实上,在修复一个错误的同时又引入 一个新的错误将是一件很令人尴尬的事。

下面,我们在同一个类中额外添加两个方法,来更加全面地进行测试:

polls/tests.py
def test_was_published_recently_with_old_question(self):
    """
    pub_date 超过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):
    """
    pub_day 在1天内的, 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() 都将返回正确的结果。

同样地,polls 应用虽然简单,但是无论它今后会变得多么复杂,会和多少其它的应用产生相互作用, 我们都能保证它会按照预期的那样工作。

测试视图

这个投票应用没有辨别能力:它会发布任何的问题,包括 pub_date 字段是未来的。我们应该改进这一点。 让 pub_date 是将来时间的问题在未来发布,直到那个时间点才会变得可见。

什么是视图测试

当我们修复上面的错误时,我们先写测试,然后修改代码来修复它。事实上,这是测试驱动开发的一个 简单的例子,但做的顺序并不真的重要。

在我们的第一个测试中,专注于代码内部的行为。在这个测试中,我们想要通过浏览器从用户的角度来 检查它的行为。

在我们试着修复任何事情之前,先来查看一下能用到的工具。

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,这些属性通常情况下是访问不到的。请注意, 这种方法 不会 建立一个测试数据库,所以以下命令将运行在现有的数据库上,输出的内容也会根据 您已经创建的问题的不同而稍有不同。如果您的 settings.py 中的的 TIME_ZONE 不正确, 那么您或许得不到预期的结果。在进行下一步之前,请确保时区设置正确。

下面我们需要导入测试客户端类(在之后的 tests.py 中,我们将使用 django.test.TestCase 类, 它具有自己的客户端,不需要导入这个类):

>>> from django.test import Client
>>> # 创建一个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&#39;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()。首先我们需要添加一个import:

polls/views.py
from django.utils import timezone

然后我们必须修改 get_queryset 方法如下:

polls/views.py
def get_queryset(self):
    """
    返回最近5个发布的问题(但不包括未来的)。
    """
    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.nowQuestion

测试我们的新视图

现在,您可以启动运行服务器,在浏览器中加载站点,创建过去和将来日期的 Questions, 并检查只列出已发布的站点,从而满足您的需求。如果您不想 每次修改可能造成影响的代码时 都重复这样做 ——— 那就基于上面的 shell 会话来创建一个测试。

将下面的代码添加到 polls/tests.py

polls/tests.py
from django.urls import reverse

我们将创建一个快捷函数来创建问题以及一个新的测试类:

polls/tests.py
def create_question(question_text, days):
    """
    创建问题有2个参数,一个是问题的文本内容 `question_text`,另外一个是发布时间相对
    当前时间的偏移天数 `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):
        """
        如果问题不存在,给出相应的提示。
        """
        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):
        """
        发布日期在过去的问题将在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):
        """
        发布日期在将来的问题不会在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):
        """
        即使同时存在过去和将来的问题,也只有过去的问卷会被显示。
        """
        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):
        """
        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.>']
        )

让我们更详细地看下以上这些内容。

第一个是问题快捷函数 create_question,功能是将创建问题的过程封装起来。

test_no_questions 不创建任何问题,但会检查消息:“No polls are available.” 并验证 latest_question_list 为空。请注意 django.test.TestCase 类提供一些额外的 断言方法。在这些例子中,我们使用了 assertContains()assertQuerysetEqual()

test_past_question 中,我们创建一个问题并验证它是否出现在列表中。

test_future_question 中,我们创建 pub_date 在未来的问题。数据库会为每个 测试方法进行重置,所以第一个问题已经不在那里,因此index页面里不应该有任何问题。

诸如此类。事实上,我们是在用测试模拟站点上的管理员输入和用户体验,检查系统的每一个状态变化, 发布的是预期的结果。

测试 详细视图(DetailView)

然而,即使未来发布的问题不会出现在 index 中,如果用户知道或者猜出正确的URL依然可以访问它们。 所以我们需要给 DetailView 视图添加一个这样的约束:

polls/views.py
class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        确认Question不是在未来发布的。
        """
        return Question.objects.filter(pub_date__lte=timezone.now())

同样,我们将增加一些测试来检查 pub_date 在过去的 Question 可以显示出来, 而 pub_date 在未来的则不可以:

polls/tests.py
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)

更多测试的思路

我们应该添加一个类似 get_queryset 方法到 ResultsView 中,并为该视图创建一个新的类。 这将与我们上面的范例非常类似,实际上也有许多重复。

我们还可以在其它方面改进我们的应用,并随之不断地增加测试。例如,发布一个没有 ChoicesQuestions 就显得极不合理。所以,我们的视图应该检查这点并排除这些 Questions。 我们的测试会创建一个不带 ChoicesQuestion 然后测试它不会被发布出来,同时创建 一个类似的 带有 ChoicesQuestion 并确保它会 发布出来。

也许登陆的管理员用户应该被允许查看还没发布的 Questions,但普通访问者则不行。最重要的是: 无论添加什么代码来完成这个要求,都需要提供相应的测试代码,不管您是先编写测试程序 然后让这些代码通过测试,还是先用代码解决其中的逻辑再编写测试程序来检验它。

从某种程度上来说,您一定会查看您的测试代码,然后想知道您的测试程序是否过于臃肿,我们接着看下面的内容:

测试越多越好

看起来我们的测试正在逐渐失去控制。以这样的速度,测试的代码量将很快超过我们的实际应用程序代码量, 对比其它简洁优雅的代码,测试代码既重复又毫无美感。

没关系! 随它去!大多数情况下,您可以编写一个测试,然后忘了它。当您继续开发您的程序时, 它将始终执行有效的测试功能。

有时,测试程序需要更新。假设我们让只具有 ChoicesQuestions 发布。在这种情况下, 许多已经存在的测试都将失败: 告诉我们哪些测试需要被修改,使得它们保持最新,所以从某种程度上讲, 测试可以测试自己。

在最坏的情况下,在您的开发过程中,您会发现许多测试变得多余。其实,这不是问题。对测试来说, 冗余是一件 事。

只要您的测试被合理地组织,它们就不会变得难以管理。好的做法是:

  • 为每个模型或视图创建一个专属的 TestClass
  • 为您想测试的每一种情况建立一个单独的测试方法
  • 为测试方法命名时最好从字面上能大概看出它们的功能

进一步测试

本教程仅介绍一些关于测试的基础。其实还有很多工作可以做,还有一些非常有用的工具可用于 实现一些非常聪明的事情。

例如,虽然我们的测试覆盖了模型的一些内部逻辑和视图发布信息的方式,但您还可以使用“基于浏览器” 的框架例如 Selenium 来测试您的 HTML 文件真实渲染的样子。这些工具不仅可以让您检查您的 Django 代码的行为,还能够检查 JavaScript 的行为。它会启动一个浏览器,与您的网站进行交互, 就像有一个人在操纵一样!Django 包含 LiveServerTestCase 来帮助与 Selenium 这样的工具集成。

如果您有一个复杂的应用,您可能为了实现 持续集成,想在每次提交代码前对代码进行自动化测试, 让代码自动(至少是部分自动)地来控制它的质量。

发现您应用中未经测试的代码的一个好方法是检查测试代码的覆盖率。这也有助于识别脆弱的甚至死代码。 如果您不能测试一段代码,这通常意味着这些代码需要被重构或者移除。Coverage将帮助我们识别死代码。 查看 Integration with coverage.py 来了解更多细节。

Testing in Django 有关于测试更加全面的信息。

下一步

关于测试的完整细节,请查看 Testing in Django

当您对 Django 视图的测试感到满意后,请阅读 本教程的第6部分 来了解静态文件的管理。