Part5 自动化测试

这篇文档从第4节结束的地方开始,我们已经创建了一个web投票应用,现在我们要为它创建一些自动化测试。

介绍一下自动化测试

什么是自动化测试

测试是一些用来检查你代码的操作的简单示例。
测试操作有不同的等级,一些测试可能会应用到非常小的细节(某个特定模型的方法是否返回期待的值?),但是其他的测试只用来检查整个软件的整体操作(在网站上的用户序列的输入是不是生成了想要的结果?)。例如在第2节里,你用shell来检查方法的属性。或者运行应用,然后输入一些数据看它是如何显示的。而本节的测试和你之前在第2节里做的测试没什么区别。

自动化测试有什么不同的地方?那就是所有的测试都由系统帮你做了。你只需要创建一组测试,然后当你在app里做了一些修改,你就能通过自动化测试来检查你的代码是不是像你预期的那样工作。而不需要执行耗时的手动测试。

为什么你需要创建测试?

那么为什么创建测试?为什么是现在?
你可能感觉到,现在为止只学习Python和Django就够了。并且可能还有其他东西要学,而且要学的东西看起来可能比Django还重要,也可能不是。毕竟,我们的投票应用现在工作的非常不错,即使创建自动化测试,也不会让它工作的更好。如果创建投票应用是你学习Django编程的最后一部分。那么很对,你不需要知道怎么创建自动化测试了。但是,如果这不止是你最后要学的东西的话,下面是学习更多内容的好时机。

测试会节约你的时间

到某个时候,‘检查看起来有效’的测试会是一个让人满意的测试。在一个更复杂的应用里,在组件之间可能会有更复杂的交互。在这些组件的任何改变,都能会导致应用的行为发生不可预料的改变。这个时候如果检查‘看起来仍然工作’意味着经过20个不同的测试数据测试以后,你的代码仍然正常工作。保证了你没有破坏应用里的任何东西。虽然这样做很浪费时间,尤其是自动化测试可以在几秒之内就能完成上面的所有测试的情况下。
如果哪里出问题了,测试可以帮助确定引发未知问题的代码。

有的时候,把你从创造性的编程工作中解放出来,去面对编写测试功能这样无聊且无趣的的工作确实是一件苦差事,特别是你知道你的代码能良好工作的时候。

测试不鉴别问题,只阻止问题

将测试当作开发消极的一面是个错误。没有测试,应用的目的或者预期的行为可能是相当不透明(或者说不可预料的)。即使是你自己写的代码,你有时候也会发现自己在尝试找出这段代码到底在做什么。

测试改变了这些情况,他们让你的代码从里面变得更透明,当出现问题的时候,他们会标识出错的部分。甚至是你没用意识到它出错的情况下。

测试让你的代码更加优雅

你可能创造了一个优秀的软件,但是你会发现其他开发者甚至都懒得看它一眼,因为它没用经过测试。没有测试,他们不会信任你的软件。Jacob Kaplan-Moss,Django的初始开发者之一,他说:“没用经过测试的代码就是被设计为用来破坏的代码”。

测试帮助团队合作

前面的观点来自一个负责维护项目的个人开发者的视角。而复杂的应用通常都是由团队来维护的,测试保证了团队成语不会再不经意间破坏你的代码(并且你不会在不知情的情况下破坏他们的)。如果你想以Django开发者的身份讨生活,你必须擅长写测试。

基本测试策略

编写测试有很多方法:
一些开发者遵循“测试驱动开发”的原则,他们通常在编写代码之前就编写测试用例。这看起来可能有点违背常识,但是实际上它和大部分人经常做的事情非常像:人们提出一个问题,然后写一些代码来解决这个问题。测试驱动开发只是简单地将这个问题应用到Python测试用例上。

更常见的是,一个新来的测试人员会写一些代码,然后决定让这些代码经过一些测试。但是如果提早写一些测试用例可能会更好,而且现在开始写测试也不迟。

有时候找到从哪开始写测试用例非常困难。如果你已经写过几千行Python代码,选择一些代码来测试可能不是那么简单。在这种情况下,在你修改代码、添加一个新特性或者修复一个bug之前写一个新的测试则会非常有帮助。

写我们的第一个测试

我们确定一个bug

幸运的是,在我们的polls应用里只有很少的bug需要修复:如果Question是在最近的一天(正确的情况下)发表的,Question.was_published_recently()方法返回True,但是如果Questionpub_date在未来的时间段内也会返回True(当然这不是需要的结果)。

要检查这个bug是不是真的存在,使用Admin创建一个questionquestion的发布日期是未来的时间,然后使用shell来检查was_published_recently()方法:

>>> 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

我们在shell里用来测试问题的代码也可以在自动化测试里做。我们来将shell里的代码转成自动化测试。

应用的测试通常是在应用的tests.py文件里,测试系统会在任何以test开头的文件里查找测试用例。把下面的代码放到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() 返回False,如果pub_date值是未来的时间
        """
        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实例,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_quesiton里,它创建了一个Question实例,实例的pub_date字段是在未来的30天。
  • 使用assertIs()方法,它发现它的was_published_recently()返回True,尽管我们想要它返回的是False

这个测试通知我们测试失败,还告诉了我们出错的那行代码。

修复bug

我们已经知道了问题在哪:如果pub_date是在未来的时间的话,Question.was_published_recently()应该返回False。修改models.py里的方法,保证它只有日期在过去的时候才返回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'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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

定位bug之后,我们写了一个测试来找到它,然后在代码里改正bug,最后测试通过。
在后面,我们的应用里可能会有很多东西出错,但是我们可以确定我们不会在不经意之间重新引入这个bug,因为只需要简单地运行一下测试,测试程序会立即警告我们。我们可以认为程序的这个部分可以永久安全的固定下来。

更全面的测试

到这里,我们可以更进一步确定was_published_recently()方法,如果在我们修复一个bug的时候又引入了另外一个bug,那就非常尴尬了。

添加另外两个test方法到相同的类里面,来更全面地测试这个方法的行为特征:

def test_was_published_recently_with_old_question(self):
    """
    was_published_recently() 在question的pub_date值是一天之前的时间返回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):
    """
    was_published_recently() 在question的pub_date值是一天之内的时间返回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()为过去、现在和将来时间的question返回合适的值。
再次声明,polls是一个简单的应用,但是不管它在将来会变得多复杂,以及它会和什么代码交互。我们现在都能有一些保证,我们测试后的方法回像期待的那样正常工作。

测试一个视图

polls应用不会对发布的问题加以区分,它会发布任何问题,包括那些pub_date字段值在未来时间的quesiton。我们必须排除这种情况,设置一个pub_date值是未来的时间意味着这个Question是在未来的那个时间才发布,所以到那个时间之前我们都看不到这个Question

视图的测试

当我们修复上面的bug的时候,我们先编写了测试用例,然后再修复它。实际上这就是测试驱动开发的一个简单的示例,但是它和我们做的这些工作的顺序并没有什么关系。
在我们第一个测试里,我们关注代码内部的行为。对于这个测试,我们希望通过让用户在web浏览器访问来测试它,进而检查它的表现。
在我们修复任何东西之前,让我们来看一下我们可以使用的工具。

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,否则,这些属性将不可用。注意这个方法不会设置一个测试数据库,所以后面的内容是在已有的数据库上运行,并且输出的内容依赖于你在数据中已经创建的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()

这些准备好以后,我们可以请求client来为我们做一些工作:

>>> # get a response from '/'
>>> response = client.get('/')
Not Found: /
>>> # 我们期待这个地址返回一个404,如果你看到一个"Invalid HTTP_POST 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?>]>

改善我们的视图

polls列表显示没有发布的poll(例如那些pub_date值是未来时间的question),我们来修复这个bug。
在第4节里我们介绍了基于类的视图,基于ListView

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
from django.utils import timezone
然后我们必须修改get_queryset方法,像下面这样:

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(pub_date__lte=timezone.now())返回一个pub_date小于或者等,即时间比timezone.now()更早或相等的Questions组成的查询集合。

测试我们的新视图

现在,通过启动runserver,在你的浏览器里加载网站,创建一些日期在过去和将来的Question,然后检查那些已经发布的Question是不是都列出来了。这样就能确定 修改后的行为特征是不是符合你的要求。你肯定不希望每次你一修改就会影响最终的结果,所以让我们再创建一个测试,基于我们上面会话里的shell。

添加下面的代码到polls/tests.py里:
from django.urls import reverse
然后我们创建一个简单的函数来创建question和一个新的测试类:

def create_question(question_text, days):
    """
    使用给定的question_text来创建一个question,并且question的pub_date的值是
    偏移指定days天数到现在。 (在测试的时候过去时间发布的question是否定的,
    未来时间发布的question是确定的)
    """
    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):
        """
        如果没有question存在,则显示一个合适的信息
        """
        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):
        """
        pub_date值是过去时间的Question会被显示在首页
        """
        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):
        """
        pub_date值是未来时间的Question不会被显示在主页上。
        """
        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):
        """
        即使过去时间和未来时间的question都有,也只会显示过去时间的question
        """
        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):
        """
        question主页可能会显示多个question
        """
        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过程中的一些重复操作剔除;
test_no_questions不创建任何questions,但是会检查信息:"No polls are availiabe."。并且确定latest_question_list是空的。注意,django.test.TestCase类提供了一些额外的中断方法,在这些示例中,我们使用了assertContains()assert QuerysetEqual()
test_past_question里,我们创建一个question并且确认它确实出现在列表里。
test_future_question里,我们创建一个pub_date值是未来时间的question,每个测试方法的数据库都被重置,所以第一个question就不会再存在,然后在索引里就不会有任何question

诸如此类,实际上,我们在使用测试来描述admin输入和用户在网站上测试的过程,并且检查每种状态,以及系统的每次状态修改,只有我们期待的结果会显示出来。

测试Detailview

我们上面做的东西可以很好地工作。但是,即使未来的question不会出现在首页上,用户也可以在知道或者猜到它们的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())

当然,我们也会添加一些测试,来检查pub_date值是过去时间的Question被正确显示出来了,并且pub_date值是未来时间的Question不会被显示出来。

class QuestionDetailViewTests(TestCase):
    def test_future_question(self):
        """
        pub_date值是未来时间的question的详细视图页面会返回一个404 not found
        """
        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):
        """
        pub_date是过去时间的question详细信息页面会显示question的文本内容
        """
        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)

上面的代码是写入到polls/tests.py文件里。

关于更多测试的想法

我们应该添加一个相似的get_queryset方法到ResultsView,并且为这个视图创建一个测试类。会和我们刚才创建的非常相似,实际上这会又很多重复的部分。

我们也可以在其他方面改善我们的应用,并且在这些方面添加测试。例如,没有ChoicesQuestions可以在网站上发布,毫无疑问这种做法非常愚蠢。所以,我们的视图可以检查这种问题,并且排除这种Question。我们的测试会创建一个没有ChoicesQuestions,然后测试它是否会被发布出来。

也许登录的管理用户会允许看未发布的Questions,但是普通的访客是不允许的。再说一遍:任何需要添加到软件里的东西都必须经过测试。不管你是先写测试再让代码通过测试,还是先在代码里实现逻辑再写测试来改进代码。

在某些时候,你必须查看你的测试代码并且确认你的代码是否经历代码测试膨胀的困扰,这就让我们来看:

当测试的时候,越多的测试越好

看起来像是我们的测试的增长已经失控了。这种情况下,在测试里面很快就有比应用更多的代码,并且相比其他优雅的代码,重复的内容是非常难看的。

没关系,让他们增长,对于大部分内容,你可以写一个使用一次的测试然后忘掉它。它会随着你继续开发你的程序一直保留着它有用的功能。

有些时候需要升级你的测试,例如我们修改了视图,然后只有带有ChoicesQuestion能够被发布出来。这种情况下,我们现有的测试会报错——告诉我们哪些tests需要修改来升级它们,所以在这个范围内测试会自己帮助自己。
更坏的情况,你继续开发,你可能会发一些有一些测试已经是多余的了。即使没有任何问题,测试冗余仍然是一件比较好的事情。
只要你的测试合理安排,它们不会变得难以管理。好的规则包括:

  • 为每一个模型或者视图添加一个隔离的TestClass
  • 为每一种你想测试的集合配置一个隔离的测试方法
  • 测试方法的名称描述它们的功能

进一步测试

这篇教程只介绍了测试的一些基础功能。但是你可以做更多的,还有一些更有用的工具能帮你做一些更聪明的事情。

例如,当我们的测试已经覆盖了模型的一些内部逻辑,以及我们的视图发布信息的方式。你可以使用一个”内部浏览器“框架,例如Selenium来测试你的HTML在浏览器里实际渲染的方式。这些工具允许你不仅仅是检查Django代码的行为特征,还可以检查其他的代码,例如你的JS代码。看到测试启动一个浏览器,并开始与您的网站互动,就好像一个人在使用它一样,会非常让人兴奋! Django包含LiveServerTestCase,以促进和Selenium这样的工具集成。

如果你有一个复杂的应用,你可能希望基于持续集成的目的,在每次提交的时候自动运行测试。至于质量控制——至少也是部分自动化的。

识别应用里未经测试部分的好方法是检查代码覆盖率。这也可以帮助确定脆弱甚至是无用的代码。如果你不能测试任何一部分代码,意味着代码必须重构或者移除。覆盖率会帮助确定无用的代码。更多详细信息,查看官方文档和coverage.py的集成(Integration with coverage.py)。
Django中的测试这篇内容里有关于测试更详细的内容。

下一步看什么?

当你熟悉了测试Django视图的内容,查看第6节的内容,学习静态文件的管理。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容