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