===================================== 编写您的第一个Django应用, 第5部分 ===================================== 本教程上接 :doc:`教程 4 `。我们已经开发了网页投票应用,现在 为它创建一些自动化测试。 自动化测试介绍 ============================= 什么是自动化测试? ------------------------- 测试是检查代码操作的简单例行程序。 测试分为不同的级别。有些测试可能用于某个细节(*比如特定的模型方法是否返回预期的值?*), 而其他测试是检查软件的整体操作(*比如站点上一系列用户的输入是否产生期望的结果?*)。 这和 :doc:`教程 2 ` 中的测试是无异的,使用 :djadmin:`shell` 来检查方法的行为,或者运行应用程序并输入数据来检查它的行为。 *自动化* 测试的不同之处在于测试工作是由系统帮您完成的。您创建一组测试,然后在对应用进行更改时, 您可以检查代码是否仍然按照原始的方式工作,而无需执行耗时的手动测试。 为什么需要自动化测试 ---------------------------- 那么为什么创建测试,为什么是现在? 您可能感觉学习 Python/Django 已经足够了,再去学习其他的东西也许需要付出巨大的努力而且没有必要, 毕竟我们的投票应用已经愉快地运行起来了。与其花时间去做自动化测试还不如改进现在的应用。如果 学习 Django 就是仅仅是为了创建一个小小投票应用,那么涉足自动化测试显然没有必要。但如果不是这样, 现在是一个很好的学习机会。 测试可以节约时间 ~~~~~~~~~~~~~~~~~~~~~~~~ 某种程度上,“检查能工作”似乎是种比较满意的测试结果。但在一些复杂的应用中,您会发现组件之间 存在各种各样复杂的交互关系。 这些组件有任何小的的更改都有可能会对应用程序的行为产生意想不到的后果。要检查它仍“能工作”, 可能意味着您需要使用二十种不同的测试数据来测试您的代码,而这仅仅是为了确保您没有做错某些事 —— 这不值得您花时间。 而自动化测试只需要数秒就可以完成以上的任务。如果出现了错误,还能够帮助找出引发这个异常行为的 代码。 有时候您可能会觉得编写测试程序相比起有价值的、创造性的编程工作显得单调乏味、无趣, 尤其是当您的代码工作正常时。 但是,比起用几个小时的时间来手动测试您的程序,或者试图找出代码中一个新生问题的原因, 编写自动化测试程序的性价比还是很高的。 测试可以发现并防止问题 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 将测试看做只是开发中消极的一面是错误的。 没有测试,应用程序的目的或预期行为可能是相当不透明的。即使这是您自己的代码,您也会发现 自己都不知道它在做什么。 测试可以改变这一情况;它们使您的代码内部变得明晰,当错误出现后,它们会明确地指出哪部分 代码出了问题 —— *甚至您自己都没意识到出现了问题*。 测试使您的代码更受欢迎 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 您可能已经创建了一个堪称辉煌的软件,但是您会发现许多其他的开发者会由于它缺少测试程序而 拒绝查看它;没有测试程序,他们不会信任它。Django最初的几个开发者之一 Jacob Kaplan-Moss 说过:“没有测试的代码是设计上的错误”。 您需要开始编写测试的另一个原因就是其他的开发者在他们认真研读您的代码前可能想要查看一下 它有没有测试。 测试有助于团队合作 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 之前的观点是从单个开发人员来维护一个程序这个方向来阐述的。复杂的应用将会被一个团队来维护。 测试能够减少同事在无意间破坏您的代码的情况(以及您在不知情的情况下破坏别人的代码的情况)。 如果您想作为Django程序员生活,您必须擅于编写测试! 基本的测试策略 ======================== 编写测试程序有很多种方法。 一些程序员遵循一种叫做“`测试驱动开发`_”的规则;他们在编写代码前会先写好测试。这看起来似乎 有点反人类,但实际上它与大多数人经常的做法很相似:先描述一个问题,然后编写代码来解决它。 测试驱动开发可以简单地在 Python 测试用例中将问题格式化。 很多时候,刚接触测试的人会先编写一些代码,再编写一些测试。事实上,更早编写一些测试会好一点, 但不管怎么说什么时候开始都不算晚。 有时候您很难决定从什么时候开始编写测试。如果您已经编写了数千行 Python 代码,挑选它们中的 一些来进行测试是不太容易的。这种情况下,在下次您对代码进行变更,添加一个新功能或者修复一个 bug 时,编写您的第一个测试,效果会非常好。下面, 让我们马上行动吧。 .. _测试驱动开发: https://en.wikipedia.org/wiki/Test-driven_development 编写我们的第一个测试 ====================== 发现bug ----------------- 很巧,在我们的 ``投票`` 应用中有一个小 bug 需要修改:当 ``Question`` 在最近的一天发布时, ``Question.was_published_recently()`` 返回True(这是正确的),然而当 ``Question`` 的 ``pub_date`` 字段是未来的日期时也返回True(这是错误的)。 要检查该 bug 是否真的存在,使用 Admin 创建一个未来的日期,并使用 :djadmin:`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 ------------------------------- 我们在 :djadmin:`shell` 中测试 bug 所做的也能在自动化测试中做到,让我们将之变为自动化测试吧。 通常,我们会把测试代码放在应用的 ``tests.py`` 文件中;测试系统将自动地从任何名字以 ``test`` 开头的文件中查找测试。 将下面的代码放入 ``投票`` 应用的 ``tests.py`` 文件中: .. snippet:: :filename: 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) 我们在这里创建了一个 :class:`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`` 应用中的测试 * 它发现了 :class:`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``: .. snippet:: :filename: 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()`` 方法更加可靠。事实上,在修复一个错误的同时又引入 一个新的错误将是一件很令人尴尬的事。 下面,我们在同一个类中额外添加两个方法,来更加全面地进行测试: .. snippet:: :filename: 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 提供了一个测试客户端 :class:`~django.test.Client` 用来模拟用户和代码的交互。 我们可以在 ``tests.py`` 甚至 :djadmin:`shell` 中使用它。 我们会再次以 :djadmin:`shell` 开始,这种方式下,需要做很多在tests.py中不必做的事。 首先是在 :djadmin:`shell` 设置测试环境:: >>> from django.test.utils import setup_test_environment >>> setup_test_environment() :meth:`~django.test.utils.setup_test_environment` 会安装一个模板渲染器,它允许我们 检查一些额外的属性比如 ``response.context``,这些属性通常情况下是访问不到的。请注意, 这种方法 *不会* 建立一个测试数据库,所以以下命令将运行在现有的数据库上,输出的内容也会根据 您已经创建的问题的不同而稍有不同。如果您的 ``settings.py`` 中的的 ``TIME_ZONE`` 不正确, 那么您或许得不到预期的结果。在进行下一步之前,请确保时区设置正确。 下面我们需要导入测试客户端类(在之后的 ``tests.py`` 中,我们将使用 :class:`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