本教程上接教程第4部分。 咱们已经创建一个网页投票应用,如今咱们将为它建立一些自动化测试。html
测试是检查你的代码是否正常运行的简单程序。python
测试能够划分为不一样的级别。 一些测试可能专一于小细节(某一个模型的方法是否会返回预期的值?), 其余的测试可能会检查软件的总体运行是否正常(用户在对网站进行了一系列的操做后,是否返回了正确的结果?)。这些其实和你早前在教程 1中作的差很少, 使用shell来检测一个方法的行为,或者运行程序并输入数据来检查它的行为方式。shell
自动化测试的不一样之处就在于这些测试会由系统来帮你完成。你建立了一组测试程序,当你修改了你的应用,你就能够用这组测试程序来检查你的代码是否仍然同预期的那样运行,而无需执行耗时的手动测试。数据库
那么,为何要建立测试?并且为何是如今?django
你可能感受学习Python/Django已经足够,再去学习其余的东西也许须要付出巨大的努力并且没有必要。 毕竟,咱们的投票应用已经活蹦乱跳了; 将时间运用在自动化测试上还不如运用在改进咱们的应用上。 若是你学习Django就是为了建立一个投票应用,那么建立自动化测试显然没有必要。 但若是不是这样,如今是一个很好的学习机会。编程
在某种程度上, ‘检查起来彷佛正常工做’将是一种使人满意的测试。 在更复杂的应用中,你可能有几十种组件之间的复杂的相互做用。浏览器
这些组件的任何一个小的变化,均可能对应用的行为产生意想不到的影响。 检查起来‘彷佛正常工做’可能意味着你须要运用二十种不一样的测试数据来测试你代码的功能,仅仅是为了确保你没有搞砸某些事 —— 这不是对时间的有效利用。安全
尤为是当自动化测试只须要数秒就能够完成以上的任务时。 若是出现了错误,测试程序还可以帮助找出引起这个异常行为的代码。服务器
有时候你可能会以为编写测试程序将你从有价值的、创造性的编程工做里带出,带到了单调乏味、无趣的编写测试中,尤为是当你的代码工做正常时。session
然而,比起用几个小时的时间来手动测试你的程序,或者试图找出代码中一个新引入的问题的缘由,编写测试程序仍是使人惬意的。
将测试看作只是开发过程当中消极的一面是错误的。
没有测试,应用的目的和意图将会变得至关模糊。 甚至在你查看本身的代码时,也不会发现这些代码真正干了些什么。
测试改变了这一切; 它们使你的代码内部变得明晰,当错误出现后,它们会明确地指出哪部分代码出了问题 —— 甚至你本身都不会料到问题会出如今那里。
你可能已经建立了一个堪称辉煌的软件,可是你会发现许多其余的开发者会因为它缺乏测试程序而拒绝查看它一眼;没有测试程序,他们不会信任它。 Jacob Kaplan-Moss,Django最初的几个开发者之一,说过“不具备测试程序的代码是设计上的错误。”
你须要开始编写测试的另外一个缘由就是其余的开发者在他们认真研读你的代码前可能想要查看一下它有没有测试。
以前的观点是从单个开发人员来维护一个程序这个方向来阐述的。 复杂的应用将会被一个团队来维护。 测试可以减小同事在无心间破坏你的代码的机会(和你在不知情的状况下破坏别人的代码的机会)。 若是你想在团队中作一个好的Django开发者,你必须擅长测试!
编写测试有不少种方法。
一些开发者遵循一种叫作“由测试驱动的开发”的规则;他们在编写代码前会先编好测试。 这彷佛与直觉不符,尽管这种方法与大多数人常常的作法很类似:人们先描述一个问题,而后建立一些代码来解决这个问题。 由测试驱动的开发能够用Python测试用例将这个问题简单地形式化。
更常见的状况是,刚接触测试的人会先编写一些代码,而后才决定为这些代码建立一些测试。 也许在以前就编写一些测试会好一点,但何时开始都不算晚。
有时候很难解决从什么地方开始编写测试。 若是你已经编写了数千行Python代码,挑选它们中的一些来进行测试不会是太容易的。 这种状况下,在下次你对代码进行变动,或者添加一个新功能或者修复一个bug时,编写你的第一个测试,效果会很是好。
如今,让咱们立刻来编写一个测试。
幸运的是,polls应用中有一个小错误让咱们能够立刻来修复它:若是Question在最后一个天发布,Question.waspublishedrecently() 方法返回True(这是对的),可是若是Question的pub_date 字段是在将来,它还返回True(这确定是不对的)。
你能够在管理站点中看到这一点; 建立一个发布时间在将来的一个Question; 你能够看到Question 的变动列表声称它是最近发布的。
你还可使用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.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.assertEqual(future_question.was_published_recently(), False)
咱们在这里作的是建立一个django.test.TestCase子类,它具备一个方法能够建立一个pubdate在将来的Question实例。而后咱们检查waspublished_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.assertEqual(future_question.was_published_recently(), False) AssertionError: True != 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开始
它运行testwaspublishedrecentlywithfuturequestion建立一个pub_date为将来30天的 Question实例
... 而后利用assertEqual()方法,它发现waspublishedrecently() 返回True,尽管咱们但愿它返回False
这个测试通知咱们哪一个测试失败,甚至是错误出如今哪一行。
咱们已经知道问题是什么:Question.waspublishedrecently() 应该返回 False,若是它的pub_date是在将来。在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'... . ---------------------------------------------------------------------- Ran 1 test in 0.001s OK Destroying test database for alias 'default'...
在找出一个错误以后,咱们编写一个测试来暴露这个错误,而后在代码中更正这个错误让咱们的测试经过。
将来,咱们的应用可能会出许多其它的错误,可是咱们能够保证咱们不会无心中再次引入这个错误,由于简单地运行一下这个测试就会当即提醒咱们。 咱们能够认为这个应用的这一小部分会永远安全了。
在这里,咱们可使waspublishedrecently() 方法更加稳定;事实上,在修复一个错误的时候引入一个新的错误将是一件很使人尴尬的事。
在同一个类中添加两个其它的测试方法,来更加综合地测试这个方法:
polls/tests.py def test_was_published_recently_with_old_question(self): """ was_published_recently() should return False for questions whose pub_date is older than 1 day. """ time = timezone.now() - datetime.timedelta(days=30) old_question = Question(pub_date=time) self.assertEqual(old_question.was_published_recently(), False) def test_was_published_recently_with_recent_question(self): """ was_published_recently() should return True for questions whose pub_date is within the last day. """ time = timezone.now() - datetime.timedelta(hours=1) recent_question = Question(pub_date=time) self.assertEqual(recent_question.was_published_recently(), True)
如今咱们有三个测试来保证不管发布时间是在过去、如今仍是将来 Question.waspublishedrecently()都将返回合理的数据。
再说一次,polls 应用虽然简单,可是不管它从此会变得多么复杂以及会和多少其它的应用产生相互做用,咱们都能保证咱们刚刚为它编写过测试的那个方法会按照预期的那样工做。
这个投票应用没有区分能力:它将会发布任何一个Question,包括 pubdate字段位于将来。咱们应该改进这一点。 设定pubdate在将来应该表示Question在此刻发布,可是直到那个时间点才会变得可见。
当咱们修复上面的错误时,咱们先写测试,而后修改代码来修复它。 事实上,这是由测试驱动的开发的一个简单的例子,但作的顺序并不真的重要。
在咱们的第一个测试中,咱们专一于代码内部的行为。 在这个测试中,咱们想要经过浏览器从用户的角度来检查它的行为。
在咱们试着修复任何事情以前,让咱们先查看一下咱们能用到的工具。
Django提供了一个测试客户端来模拟用户和代码的交互。咱们能够在tests.py 甚至在shell 中使用它。
咱们将再次以shell开始,可是咱们须要作不少在tests.py中没必要作的事。首先是在 shell中设置测试环境:
>>> from django.test.utils import setup_test_environment >>> setup_test_environment()
setuptestenvironment()安装一个模板渲染器,可使咱们来检查响应的一些额外属性好比response.context,不然是访问不到的。请注意,这种方法不会创建一个测试数据库,因此如下命令将运行在现有的数据库上,输出的内容也会根据你已经建立的Question不一样而稍有不一样。
下一步咱们须要导入测试客户端类(在以后的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('/') >>> # we should expect a 404 from that address >>> 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.core.urlresolvers import reverse >>> response = client.get(reverse('polls:index')) >>> response.status_code 200 >>> response.content '\n\n\n <p>No polls are available.</p>\n\n' >>> # note - you might get unexpected results if your ``TIME_ZONE`` >>> # in ``settings.py`` is not correct. If you need to change it, >>> # you will also need to restart your shell session >>> from polls.models import Question >>> from django.utils import timezone >>> # create a Question and save it >>> q = Question(question_text="Who is your favorite Beatle?", pub_date=timezone.now()) >>> q.save() >>> # check the response once again >>> response = client.get('/polls/') >>> response.content '\n\n\n <ul>\n \n <li><a href="/polls/1/">Who is your favorite Beatle?</a></li>\n \n </ul>\n\n' >>> # If the following doesn't work, you probably omitted the call to >>> # setup_test_environment() described above >>> response.context['latest_question_list'] [<Question: Who is your favorite Beatle?>]
投票的列表显示尚未发布的投票(即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]
response.contextdata['latestquestion_list'] 取出由视图放置在context 中的数据。
咱们须要修改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(pubdatelte=timezone.now()) 返回一个查询集,包含pubdate小于等于timezone.now的Question。
启动服务器、在浏览器中载入站点、建立一些发布时间在过去和未来的Questions ,而后检验只有已经发布的Question会展现出来,如今你能够对本身感到满意了。你不想每次修改可能与这相关的代码时都重复这样作 —— 因此让咱们基于以上shell会话中的内容,再编写一个测试。
将下面的代码添加到polls/tests.py:
polls/tests.py from django.core.urlresolvers import reverse
咱们将建立一个快捷函数来建立Question,同时咱们要建立一个新的测试类:
polls/tests.py def create_question(question_text, days): """ Creates a question with the given `question_text` 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 QuestionViewTests(TestCase): def test_index_view_with_no_questions(self): """ If no questions exist, an appropriate message should be 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_index_view_with_a_past_question(self): """ Questions with a pub_date in the past should be displayed on the index page. """ 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_index_view_with_a_future_question(self): """ Questions with a pub_date in the future should not be 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.", status_code=200) self.assertQuerysetEqual(response.context['latest_question_list'], []) def test_index_view_with_future_question_and_past_question(self): """ Even if both past and future questions exist, only past questions should be displayed. """ 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_index_view_with_two_past_questions(self): """ The questions index page may display multiple questions. """ 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.>'] )
让咱们更详细地看下以上这些内容。
第一个是Question的快捷函数create_question,将重复建立Question的过程封装在一块儿。
testindexviewwithnoquestions不建立任何Question,但会检查消息“No polls are available.” 并验证latestquestion_list为空。注意django.test.TestCase类提供一些额外的断言方法。在这些例子中,咱们使用assertContains() 和 assertQuerysetEqual()。
在testindexviewwithapastquestion中,咱们建立一个Question并验证它是否出如今列表中。
在testindexviewwithafuturequestion中,咱们建立一个pub_date 在将来的Question。数据库会为每个测试方法进行重置,因此第一个Question已经不在那里,所以首页面里不该该有任何Question。
等等。 事实上,咱们是在用测试模拟站点上的管理员输入和用户体验,检查针对系统每个状态和状态的新变化,发布的是预期的结果。
一切都运行得很好; 然而,即便将来发布的Question不会出如今index中,若是用户知道或者猜出正确的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())
固然,咱们将增长一些测试来检验pubdate 在过去的Question 能够显示出来,而pubdate在将来的不能够:
polls/tests.py class QuestionIndexDetailTests(TestCase): def test_detail_view_with_a_future_question(self): """ The detail view of a question with a pub_date in the future should return a 404 not found. """ future_question = create_question(question_text='Future question.', days=5) response = self.client.get(reverse('polls:detail', args=(future_question.id,))) self.assertEqual(response.status_code, 404) def test_detail_view_with_a_past_question(self): """ The detail view of a question with a pub_date in the past should display the question's text. """ past_question = create_question(question_text='Past Question.', days=-5) response = self.client.get(reverse('polls:detail', args=(past_question.id,))) self.assertContains(response, past_question.question_text, status_code=200)
咱们应该添加一个相似get_queryset的方法到ResultsView并为该视图建立一个新的类。这将与咱们刚刚建立的很是相似;实际上将会有许多重复。
咱们还能够在其它方面改进咱们的应用,并随之不断增长测试。例如,发布一个没有Choices的Questions就显得傻傻的。因此,咱们的视图应该检查这点并排除这些 Questions。咱们的测试应该建立一个不带Choices 的 Question而后测试它不会发布出来, 同时建立一个相似的带有 Choices的Question 并验证它会 发布出来。
也许登录的用户应该被容许查看还没发布的 Questions,但普通游客不行。 再说一次:不管添加什么代码来完成这个要求,须要提供相应的测试代码,不管你是不是先编写测试而后让这些代码经过测试,仍是先用代码解决其中的逻辑而后编写测试来证实它。
从某种程度上来讲,你必定会查看你的测试,而后想知道是否你的测试程序过于臃肿,这将咱们带向下面的内容:
看起来咱们的测试代码的增加正在失去控制。 以这样的速度,测试的代码量将很快超过咱们的应用,对比咱们其它优美简洁的代码,重复毫无美感。
不要紧。让它们继续增加。最重要的是,你能够写一个测试一次,而后忘了它。 当你继续开发你的程序时,它将继续执行有用的功能。
有时,测试须要更新。 假设咱们修改咱们的视图使得只有具备Choices的 Questions 才会发布。在这种状况下,咱们许多已经存在的测试都将失败 —— 这会告诉咱们哪些测试须要被修改来使得它们保持最新,因此从某种程度上讲,测试能够本身照顾本身。
在最坏的状况下,在你的开发过程当中,你会发现许多测试如今变得冗余。 即便这样,也不是问题;对测试来讲,冗余是一件好 事。
只要你的测试被合理地组织,它们就不会变得难以管理。 从经验上来讲,好的作法是:
每一个模型或视图具备一个单独的TestClass
为你想测试的每一种状况创建一个单独的测试方法
测试方法的名字能够描述它们的功能
本教程只介绍了一些基本的测试。 还有不少你能够作,有许多很是有用的工具能够随便使用来你实现一些很是聪明的作法。
例如,虽然咱们的测试覆盖了模型的内部逻辑和视图发布信息的方式,你可使用一个“浏览器”框架例如Selenium来测试你的HTML文件在浏览器中真实渲染的样子。 这些工具不只可让你检查你的Django代码的行为,还可以检查你的JavaScript的行为。 它会启动一个浏览器,并开始与你的网站进行交互,就像有一我的在操纵同样,很是值得一看! Django 包含一个LiveServerTestCase来帮助与Selenium 这样的工具集成。
若是你有一个复杂的应用,你可能为了实现continuous integration,想在每次提交代码后对代码进行自动化测试,让代码自动 —— 至少是部分自动 —— 地来控制它的质量。
发现你应用中未经测试的代码的一个好方法是检查测试代码的覆盖率。 这也有助于识别脆弱的甚至死代码。 若是你不能测试一段代码,这一般意味着这些代码须要被重构或者移除。 Coverage将帮助咱们识别死代码。 查看与coverage.py 集成来了解更多细节。
Django 中的测试有关于测试更加全面的信息。
关于测试的完整细节,请查看Django 中的测试。
当你对Django 视图的测试感到满意后,请阅读本教程的第6部分来 了解静态文件的管理。
译者:Django 文档协做翻译小组,原文:Part 5: Testing。
本文以 CC BY-NC-SA 3.0 协议发布,转载请保留做者署名和文章出处。
Django 文档协做翻译小组人手紧缺,有兴趣的朋友能够加入咱们,彻底公益性质。交流群:467338606。