结束了教程4之后,我们已经建立了一个网络投票应用程序,现在为该应用创建自动化测试
测试是用于检验代码运行正确行的简单程序
测试也分成多种级别。一些测试只用于检查某个细节(比如:某个模型方法是否返回期望得到的值),而还有一些用于测试完整的软件的运行(用户在网站上的输入序列是否会生成期望的结果)。这些测试方法跟教程1中的测试方法没什么两样,使用shell来检验方法的执行是否正确或者运行一个应用然后输入数据检查输出的结果是否与自己期望的输出一致。
那么自动化测试与之前的测试有那些不同呢?那就是,你只需要根据被测试的程序创建一次测试用例,即使之后你修改了代码,还是可以使用前面创建好的测试用力来测试你的代码。而不在需要进行浪费时间的手动测试。
为什么要编写测试用例,为什么是现在?
你可能觉得仅仅学习python/django就够你受的了,居然还要学那些看似必不可少但有可能是无用武之地的东西。毕竟,我们的投票应用程序一直都能很好的工作。那么麻烦的创建为它创建自动化测试也不会让它变得更好。没错,如果自动化测试是完成投票应用的最后一步,那就没有创建自动化测试的必要了。但是,事实并非如此,现在才是创建自动化测试的最佳时机。
某种程度来说,“检查程序是否正常工作”是一个不错的测试方案。在更加复杂的应用程序中,那里有几十个组件之间复杂的相互作用。
对组件有任何的改动都会对程序的执行产生意想不到的后果。“检查程序是否正常工作”意味着对应用程序的各个部分的功能分别通过20组不同的数据作为输入来进行测试,以上这些测试仅仅是用于保证你的改动没有错误,这不是测试的意义所在。
不可否认,自动化测试能够在几秒内帮你做到定位程序异常的位置。
有时,测试就像一个苦差事把你从具有创造性的编程工作中脱离出来,让你陷入编写测试用例,测试代码准确性的乏味无趣的工作中。然而,编写测试用例来测试程序定位问题比手动测试和定位新引入问题的位置效率更高。
把测试作为软件开发中消极的一面,认为测试只是把存在的bug找出来,是错误的想法。
没有测试,应用程序的目的或者意图会变得更加难以理解。即使是自己编写的代码,有时候也要花很长的时间才能明白那些代码究竟是做什么的。
测试让这种状况得到改善。测试代码会在被测试代码出现错误是标记出出现错误的部分,即使连你自己都没有意识到哪里出错了。
也许你实现的那一部分代码很牛逼,但是你会发现其他的开发者会直接了当的拒绝阅读你的代码,以为它们缺少测试代码。没有测试代码,其他的开发者是不会信任你所创造出来的代码有多么多么的好。 Jacob Kaplan-Moss说过,“没有测试的代码从设计开始就已经失败了”(也就是说代码的测试从软件设计开始就要考虑的)
其他的开发者在真正使用你的代码之前会先测试你的代码,这是编写测试的另外一个原因。
前面的观点是个人项目开发者的观点,但是如果是由团队来维护一个复杂项目,测试确保你的代码不会被同事破坏(当然你不能在他们毫不知情的情况下破坏他们的代码)。要想成为一个django程序员,你就必须善于编写测试用例。
编写测试的方法有很多
有些程序员遵循“测试驱动开发”的开发原则,在写代码之前编写测试用例。咋一看,似乎有点本末倒置,但是事实上这种做法与很多人的开发方式是类似的:先描述问题,然后编写代码解决问题。测试驱动开发则是使用测试用例来简单描述问题。
很多时候,对编写测试的新手,他们会先编写一点代码,然后才去编写代码的测试用例。也许越早设计测试用例越好,但是只要开始编写用例就永远也不算晚。
有时很难决定应该什么时候编写测试用例。但是如果你已经编写了成千上万行代码的时候,才选择去编写测试用例,这不是一件简单的事情。在这种情况下,写下第一个测试用例,那么在下一次对代码进行修改(不论是添加新的功能还是解bug)都会让你获益多多。
那么,让我开始编写测试用例吧!
幸运的是,投票应用程序中有小小的bug,测试用例就有了用武之地了:在Poll.was_published_recently()方法,不论投票是最近一天发布(True)的还是未来的某天发布(期望返回False)的都会返回True。
你可以在管理站点,创建一个发布时间为未来某天的投票,会发现这条投票会出现在最近发布投票的列表中。
你也可以使用shell看到这个bug:
>>> import datetime >>> from django.utils import timezone >>> from polls.models import Poll >>> # create a Poll instance with pub_date 30 days in the future >>> future_poll = Poll(pub_date=timezone.now() + datetime.timedelta(days=30)) >>> # was it published recently? >>> future_poll.was_published_recently() True
因为未来某天并不在最近的范畴里,这显然是bug。
前面在shell中的bug暴露的过程就是我们编写测试用例的要做的事情,让我们把这个测试用例写出来。
通常测试用例都会保存在应用的tests.py文件中,django的测试系统会自动的找到这个所有以test开头的文件里面的测试用例并执行。
把下面的代码,写到polls应用的tests.py文件中:
import datetime from django.utils import timezone from django.test import TestCase from polls.models import Poll class PollMethodTests(TestCase): def test_was_published_recently_with_future_poll(self): """ was_published_recently() should return False for polls whose pub_date is in the future """ future_poll = Poll(pub_date=timezone.now() + datetime.timedelta(days=30)) self.assertEqual(future_poll.was_published_recently(), False)
上面我们创建了一个django测试用例。定义了TestCase子类PollMethodTests,在它的test_was_published_recently_with_future_poll方法中创建一个poll实例,初始化pub_date的值为未来的第30天,然后检查poll实例的was_published_recently()方法返回值是否等于False。
在终端中,输入:
$ python manage.py test polls
你会看到类似以下的输出:
Creating test database for alias 'default'... F ====================================================================== FAIL: test_was_published_recently_with_future_poll (polls.tests.PollMethodTests) ---------------------------------------------------------------------- Traceback (most recent call last): File "/path/to/mysite/polls/tests.py", line 16, in test_was_published_recently_with_future_poll self.assertEqual(future_poll.was_published_recently(), False) AssertionError: True != False ---------------------------------------------------------------------- Ran 1 test in 0.001s FAILED (failures=1) Destroying test database for alias 'default'...
这些输出到底是怎么来的呢?
1、python manage.py test polls 这个命令会找到polls应用中的测试用例
2、接着,找到django.test.TestCase的子类的定义,这里是PollMethodTests
3、然后,创建一个专门用于测试的数据库文件
4、这时,在测试用例子类中找到以test开头的方法(这里是:test_was_published_recently_with_future_poll),然后执行它
5、最后,根据执行结果输出测试信息
上面的测试信息告诉我们那个测试用例运行失败了,还把导致失败的语句行号报告出来。
知道了bug:如果pub_date>的值是未来某天,Poll.was_published_recently()应该返回False。在models.py把bug修复,让它在pub_date仅为过去时间时,才返回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'... . ---------------------------------------------------------------------- Ran 1 test in 0.001s OK Destroying test database for alias 'default'...
在确定了bug之后,编写测试用例暴露出bug,然后在代码中修复bug,最后代码测试通过。
也许未来某一天我们的应用还会出现bug,但是不再会是这个bug。因为一旦出现同样的bug,测试用例会给出马上给出警告。这一部分一小段程序防止bug重复出现为程序的安全提供了永久的保障。
还可以对was_published_recently()方法进行更多的测试。事实上,会出现修复一个bug后又引入新的bug的尴尬情况。因此为was_published_recently()方法再添加两个测试方法,来对它进行更加全面的测试。
代码如下:
def test_was_published_recently_with_old_poll(self): """ was_published_recently() should return False for polls whose pub_date is older than 1 day """ old_poll = Poll(pub_date=timezone.now() - datetime.timedelta(days=30)) self.assertEqual(old_poll.was_published_recently(), False) def test_was_published_recently_with_recent_poll(self): """ was_published_recently() should return True for polls whose pub_date is within the last day """ recent_poll = Poll(pub_date=timezone.now() - datetime.timedelta(hours=1)) self.assertEqual(recent_poll.was_published_recently(), True)
现在我们有三个测试方法来确认Poll.was_published_recently()方法对过去,最近,未来三种不同的投票发布时间有正确对应的正确的返回值。
再强调一次,polls是一个简单的应用,但是无论未来它变得多么复杂或者它会和什么样的代码交互,我们都为它的每个方法编写了测试用例,确保它正确运行,得到期望的结果。
这个polls应用有点不合理:它可以发布任何投票,包括发布时间是未来的某天的投票。这是个bug,应该修复。设置pub_date为未来某天的投票理应在pub_date那天之前是不可见状态的。
在修复前面的models中的bug的步骤是先编写测试代码,然后运行测试代码来定位bug,最后修复它。事实上,那是一个简单的测试驱动开发的例子,但是到底按照什么样的步骤来实施并不要紧。
在我们第一个测试中,我们仅仅围绕着代码的内部逻辑在进行。在这次测试,我们要检查代码通过浏览器展现给用户的内容是否与我们的期望一致。
在修复bug之前,我们先搞清楚自己掌握了那些工具。
django提供一个测试客户端在视图级别上来模拟用户与代码的交互。可以在tests.py或者shell中使用它。
我们又可以从shell开始,在这里我们需要先做两件在tests.py中不需要做的事情。第一件是设置测试环境:
>>> from django.test.utils import setup_test_environment >>> setup_test_environment()
setup_test_environment()安装一个模板渲染器,允许我们来测试response额外的属性。否则上下文无法使用。注意这个方法还没有安装测试数据库,因此需要再一次运行前面已经创建了的测试数据库。输出信息会因为所创建的polls实例而不同。
接下来,我们需要导入test 客户端类(后面tests.py文件中我们将使用django.test.TestCase类,它本身自带客户端类,因此不需要额外导入客户端)。
>>> from django.test.client import Client >>> # create an instance of the client for our use >>> client = 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 harcoded URL >>> from django.core.urlresolvers import reverse >>> response = client.get(reverse('polls:index')) >>> response.status_code 200 >>> response.content ' <p>No polls are available.</p> ' >>> # 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 Poll >>> from django.utils import timezone >>> # create a Poll and save it >>> p = Poll(question="Who is your favorite Beatle?", pub_date=timezone.now()) >>> p.save() >>> # check the response once again >>> response = client.get('/polls/') >>> response.content ' <ul> <li><a href="/polls/1/">Who is your favorite Beatle?</a></li> </ul> ' >>> response.context['latest_poll_list'] [<Poll: Who is your favorite Beatle?>]
投票列表把还没有发布的投票也显示出来了。(比如,那些pub_date是将来时间的投票)。让我们把它修复。
在教程4中,我们使用了通用视图,IndexView类是基于ListView的:
class IndexView(generic.ListView): template_name = 'polls/index.html' context_object_name = 'latest_poll_list' def get_queryset(self): """Return the last five published polls.""" return Poll.objects.order_by('-pub_date')[:5]
response.context_data['latest_poll_list']提取出视图在上下文中设置的数据。
修改get_queryset方法,使它也可以与当前时间比较来检查时间。
首先需要导入:
from django.utils import timezone
接着把get_queryset方法改成如下样子:
def get_queryset(self): """ Return the last five published polls (not including those set to be published in the future). """ return Poll.objects.filter(pub_date__lte=timezone.now()).order_by('-pub_date')[:5]
Poll.objects.filter(pub_date__lte=timezone.now())返回一个queryset,它包含pub_date小于或者等于timezone.now的Polls。
基于前面的shell会话,我们来创建自己的视图测试实例:
在polls/tests.py中添加代码:
from django.core.urlresolvers import reverse
我们将使用工厂方法来创建poll实例和测试类。
def create_poll(question, days): """ Creates a poll with the given `question` published the given number of `days` offset to now (negative for polls published in the past, positive for polls that have yet to be published). """ return Poll.objects.create(question=question, pub_date=timezone.now() + datetime.timedelta(days=days)) class PollViewTests(TestCase): def test_index_view_with_no_polls(self): """ If no polls 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_poll_list'], []) def test_index_view_with_a_past_poll(self): """ Polls with a pub_date in the past should be displayed on the index page. """ create_poll(question="Past poll.", days=-30) response = self.client.get(reverse('polls:index')) self.assertQuerysetEqual(response.context['latest_poll_list'],['<Poll: Past poll.>']) def test_index_view_with_a_future_poll(self): """ Polls with a pub_date in the future should not be displayed on the index page. """ create_poll(question="Future poll.", days=30) response = self.client.get(reverse('polls:index')) self.assertContains(response, "No polls are available.", status_code=200) self.assertQuerysetEqual(response.context['latest_poll_list'], []) def test_index_view_with_future_poll_and_past_poll(self): """ Even if both past and future polls exist, only past polls should be displayed. """ create_poll(question="Past poll.", days=-30) create_poll(question="Future poll.", days=30) response = self.client.get(reverse('polls:index')) self.assertQuerysetEqual( response.context['latest_poll_list'],['<Poll: Past poll.>']) def test_index_view_with_two_past_polls(self): """ The polls index page may display multiple polls. """ create_poll(question="Past poll 1.", days=-30) create_poll(question="Past poll 2.", days=-5) response = self.client.get(reverse('polls:index')) self.assertQuerysetEqual(response.context['latest_poll_list'],['<Poll: Past poll 2.>', '<Poll: Past poll 1.>'])
让我们深入探讨上面的代码。第一个是poll的工厂方法,create_poll,把创建poll实例的重复步骤抽离出来。test_index_view_with_no_polls没有创建任何的poll实例,但是检查程序是否输出消息“No polls are available”和验证latest_poll_list是否为空。注意:django.test.TestCase类提供了一些额外的断言方法。在这里我们用到了assertContains()和assertQuerysetEqual()。
在test_index_view_with_a_past_poll中,我们创建一个poll实例然后验证它是否会在poll列表中出现。
在test_index_view_with_a_future_poll中,同样创建一个poll,pub_date的时间为将来时。在每个测试方法中,数据库都会重置,以使前面方法创建poll不会出现的数据库里,这时候数据库应该是空的。
等等。实际上,我们使用测试讲了一个故事,关于管理员输入了那些数据还有用户使用我们的网站看到了什么。对于检查系统每一个状态以及状态的变化的结果都是可见的。
我们的代码工作正常。但是,即使future polls(pub_date is future),没有在index视图中显示出来,但是用户也可以通过正确的url访问到它们。所以需要在DetailView中对此加以约束。
class DetailView(generic.DetailView): ... def get_queryset(self): """ Excludes any polls that aren't published yet. """ return Poll.objects.filter(pub_date__lte=timezone.now())
当然,我们还要编写一些测试代码,来检查一个pub_date is past的Poll能否正确显示,而pub_date is future是否不能被访问到。
class PollIndexDetailTests(TestCase): def test_detail_view_with_a_future_poll(self): """ The detail view of a poll with a pub_date in the future should return a 404 not found. """ future_poll = create_poll(question='Future poll.', days=5) response = self.client.get(reverse('polls:detail', args=(future_poll.id,))) self.assertEqual(response.status_code, 404) def test_detail_view_with_a_past_poll(self): """ The detail view of a poll with a pub_date in the past should display the poll's question. """ past_poll = create_poll(question='Past Poll.', days=-5) response = self.client.get(reverse('polls:detail', args=(past_poll.id,))) self.assertContains(response, past_poll.question, status_code=200)
我们应该为ResultsView视图添加类似与get_queryset方法,然后为该视图创建一个新的测试类。这些工作与前面的有大量的重复。poll应用还有很多地方可以提高的,比如那些没有Choice的投票就不要显示出来。也许,登录状态的管理员可以查看没有发布的Polls,而不是普通的访问者。还是那句话:无论添加任何功能都需要附带对应的测试代码,而编写测试和编写代码次序无关紧要。有一天你会发现,测试代码一直在不断的膨胀。
看起来我们的测试代码的增长失去了控制。按照这样的速度,它们就会超过正常代码的数量了。而且相对简洁优雅的正常代码来说,里面有很多重复和不美观的代码。
没关系,让它们增长。在大多数情况下,你只需要测试一次然后忘掉它。在你后续的开发中,它也能很好的工作。有时,测试也需要升级。比如前面提到的改善建议(不显示没有choice的poll),会使前面编好的测试代码会执行失败,单同时也告诉我们那些测试需要升级。测试代码可以自检。
还有最坏的情况,有时候你会发现很多测试代码是冗余的,但是你还是可以继续开发,完全不受影响。而且冗余是一件好事。
只要你对测试代码合理安排,它们就不会变得乱糟糟。原理原则:
1、一个model/view对应一个独立的TestClass类
2、一个test method 对应一种你要测试的情况
3、使用测试功能来命名test method