Python-测试初学者指南-全-

Python 测试初学者指南(全)

原文:zh.annas-archive.org/md5/50a442da335e51ba09d9df62d92a7438

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

就像任何程序员一样,你需要能够生成符合规范的可靠代码,这意味着你需要测试你的代码。在这本书中,你将学习如何使用技术和 Python 工具来减少测试中的工作量,同时使其更加有用——甚至有趣。

你将了解 Python 的几个自动化测试工具,以及它们旨在支持的理念和方法,如单元测试和测试驱动开发。完成之后,你将能够比以往任何时候都更快、更轻松地生成经过充分测试的代码,并且以一种不会分散你“真正”编程的方式完成。

本书涵盖的内容

第一章:测试以娱乐和盈利介绍了 Python 测试驱动开发和各种测试方法。

第二章:Doctest:最简单的测试工具介绍了 doctest 工具,并教你如何使用它。

第三章:使用 Doctest 进行单元测试介绍了单元测试和测试驱动开发的概念,并将 doctest 应用于创建单元测试。

第四章:通过使用 Mock 对象打破紧密耦合涵盖了 Mock 对象和 Python Mocker 工具。

第五章:当 Doctest 不够用时:Unittest 来拯救介绍了 unittest 框架,并讨论了何时它比 doctest 更受欢迎。

第六章:运行测试:顺其自然介绍了 Nose 测试运行器,并讨论了项目组织。

第七章:开发测试驱动型项目详细介绍了完整的测试驱动开发过程。

第八章:使用 Twill 测试 Web 应用程序前端将前几章中获得的知识应用于 Web 应用程序,并介绍了 Twill 工具。

第九章:集成测试和系统测试教授如何从单元测试构建到完整的软件系统测试。

第十章:其他测试工具和技术介绍了代码覆盖率和持续集成,并教授如何将自动化测试与版本控制系统相结合。

附录:常见问题解答的答案包含了所有常见问题解答的答案,按章节划分。

为本书所需的条件

要使用这本书,你需要一个可工作的 Python 解释器,最好是 2.6 版本系列之一。你还需要一个源代码编辑器,以及偶尔访问互联网。你需要足够熟悉你操作系统的文本界面——你的 DOS 提示符或命令外壳——以进行基本的目录管理和运行程序。

这本书面向的对象

如果你是一名 Python 开发者,并想为你的应用程序编写测试,这本书将帮助你入门,并展示学习测试的最简单方法。

你需要具备扎实的 Python 编程知识才能跟上。了解软件测试会有帮助,但不需要正式的测试知识,也不需要了解书中讨论的任何库。

习惯用法

在这本书中,你会发现几个经常出现的标题。

为了清楚地说明如何完成一个程序或任务,我们使用:

行动时间 - 标题

  1. 行动 1

  2. 行动 2

  3. 行动 3

指令通常需要一些额外的解释,以便它们有意义,因此它们后面跟着:

刚才发生了什么?

这个标题解释了你刚刚完成的任务或指令的工作原理。

你还会在书中找到一些其他的学习辅助工具,包括:

快速测验标题

这些是旨在帮助你测试自己理解的小型多项选择题。

尝试一下英雄式标题

这些设置了实际挑战,并为你提供了实验所学内容的想法。

你还会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词如下所示:“我们可以通过使用include指令来包含其他上下文。”

代码块如下设置:

...     if node.right is not None:
...         assert isinstance(node.right, AVL)
...         assert node.right.key > node.key
...         right_height = node.right.height + 1

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

...     if node.right is not None:
...         assert isinstance(node.right, AVL)
...         assert node.right.key > node.key
...         right_height = node.right.height + 1

任何命令行输入或输出如下所示:

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
     /etc/asterisk/cdr_mysql.conf

新术语重要词汇以粗体显示。你在屏幕上看到的词,例如在菜单或对话框中,在文本中如下所示:“点击 Next 按钮将你带到下一个屏幕”。

注意

警告或重要注意事项以这样的框显示。

小贴士

小技巧和窍门看起来像这样。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。

要发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书名。

如果有你想让我们出版且需要看到的书籍,请通过 www.packtpub.com 上的建议标题表格或发送电子邮件至<suggest@packtpub.com>给我们留言。

如果您在某个主题上具有专业知识,并且您对撰写或参与一本书感兴趣,请参阅我们的作者指南 www.packtpub.com/authors

客户支持

现在,您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

小贴士

下载本书的示例代码

访问 www.packtpub.com/files/code/8846_Code.zip 直接下载示例代码。

可下载的文件包含如何使用它们的说明。

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/support,选择您的书籍,点击让我们知道链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被添加到任何现有的勘误列表中。任何现有的勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

侵权

互联网上版权材料的侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过 <copyright@packtpub.com> 与我们联系,并提供疑似侵权材料的链接。

我们感谢您在保护我们的作者以及为我们带来有价值内容的能力方面的帮助。

询问

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章:测试以乐趣和利润为目的

你是一名程序员:一个编码者、开发者,或者可能是一名黑客!作为这样的角色,你几乎不可能没有坐下来检查一个你确信已经准备好使用的程序——或者更糟糕的是,一个你知道还没有准备好的程序——并编写一系列测试来证明它的正确性。这通常感觉像是一项徒劳的练习,或者最多是浪费时间。我们将学习如何避免这种情况,使测试变得简单且愉快。

这本书将向你展示一种新的测试方法,这种方法将测试的大部分负担放在了它应该放的地方:计算机上。更好的是,你的测试将帮助你早期发现问题,并告诉你它们在哪里,这样你就可以轻松地修复它们。你将爱上这本书中你将学到的简单、有用的自动化测试和测试驱动开发方法。

当谈到测试时,Python 语言拥有一些最好的工具。因此,我们将通过利用这些工具,学习如何使测试变得容易、快捷且有趣。

在这本书中,我们将:

  • 研究流行的测试工具,如 doctest、unittest 和 Nose。

  • 了解测试哲学,如单元测试和测试驱动开发。

  • 检查使用模拟对象和其他有用的测试秘诀。

  • 学习如何将测试与其他我们使用的工具以及我们的工作流程相结合。

  • 介绍一些辅助工具,使使用主要测试工具变得更加容易。

测试如何帮助?

本章开始时提出了许多宏伟的声明,例如:你会喜欢测试。你会依赖它来帮助你早期和轻松地消灭虫子。测试将不再成为你的负担,而变成你愿意去做的事情。你可能想知道这是怎么可能的?

回想一下你最近遇到的最后一个令人烦恼的错误。它可能是什么;数据库模式不匹配,或者糟糕的数据结构。

记住是什么导致了错误?那一行有细微逻辑错误的代码?那个没有按照文档说明执行的功能?无论是什么,都要记住。

想象一下,如果有一小块代码在正确的时间运行,就能捕捉到错误,并通知你。

现在想象一下,如果你的所有代码都伴随着那些小块的测试代码,并且它们执行起来既快又简单。

你的错误会持续多久?不会很久。

这就为你提供了一个基本理解,我们将在这本书中讨论什么。有许多工具和改进可以使这个过程更快、更简单。基本思想是告诉计算机你期望什么,使用简单且易于编写的代码块,然后让计算机在整个编码过程中双重检查你的期望。由于期望容易描述,你可以先写下来,让计算机承担调试代码的大部分负担。因此,你可以在计算机跟踪其他一切的同时,继续做有趣的事情。

当你完成时,你将拥有一个高度测试的代码库,你可以对其充满信心。你将能够尽早捕捉到错误并迅速修复它们。最好的部分是,你的测试是基于你告诉计算机程序应该做什么来进行的。毕竟,为什么你要亲自做,当计算机可以为你做的时候?

我已经编写了简单的自动化测试来捕捉从轻微的打字错误,到在模式更改后数据库访问代码被危险地留在了过时状态的情况,以及几乎任何你能想象到的其他错误。测试迅速捕捉到错误,并确定了它们的位置。由于它们的存在,避免了大量的努力和麻烦。

想象一下,你将节省或花费在编写新功能上的时间,而不是追逐旧错误。编写更快的代码,有良好的成本效益比。正确地进行测试确实既有趣又有利可图。

测试类型

测试通常根据被测试组件的复杂程度分为几个类别。我们的大部分时间将集中在最低级别——单元测试——因为其他类别的测试基本上遵循相同的原理。

单元测试

单元测试是对程序可能的最小部分进行的测试。通常,这意味着单个函数或方法。这里的重点是单个;如果没有什么有意义的办法可以进一步分割它,那么它就是一个单元

单元测试用于单独测试单个单元,验证它是否按预期工作,而不考虑程序的其他部分会做什么。这保护每个单元免受其他地方犯下的错误的影响,并使得缩小到实际问题变得容易。

单独来看,单元测试不足以确认完整程序是否正确工作,但它是一切其他事物的基础。没有坚固的材料,你不能建造房子;没有按预期工作的单元,你不能建造程序!

集成测试

在集成测试中,隔离的边界被进一步推后,因此测试涵盖了相关单元之间的交互。每个测试仍然应该单独运行,以避免从外部继承问题,但现在测试检查的是被测试的单元是否作为一个整体正确地表现。

集成测试可以使用与单元测试相同的工具进行。因此,自动化测试的新手有时会被诱使忽视单元测试和集成测试之间的区别。忽视这个区别是危险的,因为这样的多功能测试通常会对它们所涉及的某些单元的正确性做出假设。这意味着测试者失去了自动化测试本应带来的许多好处。我们直到它们咬我们才会意识到我们做出的假设,因此我们需要有意识地选择以最小化假设的方式工作。这就是为什么我把测试驱动开发称为纪律的原因。

系统测试

系统测试将隔离的边界扩展得甚至更远,到了它们甚至不存在的地方。系统测试在将整个程序连接在一起之后检查程序的部分。从某种意义上说,系统测试是集成测试的一种极端形式。

系统测试非常重要,但没有集成测试和单元测试,它们几乎没有什么用处。在你能确信整体之前,你必须确信各个部分。如果某个地方存在细微的错误,系统测试会告诉你它存在,但不会告诉你它在哪或如何修复它。你很可能之前经历过这种情况;这可能是你讨厌测试的原因。

你有 Python,对吧?

本书假设您对 Python 编程语言有实际的知识,并且您有一个功能齐全的 Python 解释器可用。假设您至少有 Python 2.6 版本,您可以从www.python.org/下载。如果您有更早的版本,不用担心:这里有一些侧边栏可以帮助您了解差异。您还需要您最喜欢的文本编辑器。

摘要

在本章中,我们了解了这本书的内容以及可以期待什么。我们简要地了解了自动化测试和测试驱动开发的哲学。

我们讨论了不同类型的测试组合在一起形成一个完整的测试套件,即:单元测试、集成测试和系统测试。我们了解到单元测试与程序的基本组件(如函数)相关,集成测试覆盖程序更大的部分(如模块),而系统测试则涵盖整个程序的测试。

我们学习了如何通过将测试的大部分负担转移到计算机上来,自动化测试能帮助我们。你可以告诉计算机如何检查你的代码,而不是自己亲自进行检查。这使得在早期和更频繁地检查代码变得方便,让你避免错过你本应注意到的事情,并帮助你快速定位和修复错误。

我们对测试驱动开发进行了一些探讨,这是一种先编写测试,然后让它们告诉你需要做什么,以便编写所需代码的纪律。

我们还讨论了您在阅读这本书的过程中所需的发展环境。

既然我们已经了解了这片土地的情况(换句话说),我们现在准备开始编写测试——这是下一章的主题。

第二章. Doctest:最简单的测试工具

本章将向你介绍一个名为 doctest 的神奇工具。Doctest 是 Python 附带的一个程序,它允许你以对人和计算机都容易阅读的方式写下你对代码的期望。Doctest 文件通常可以通过从 Python 交互式外壳中复制文本并将其粘贴到文件中来创建。Doctest 通常是编写软件测试最快、最简单的方式。

在本章中,我们将:

  • 学习 doctest 语言和语法

  • 在文本文件中编写嵌入的 doctests

  • 在 Python 文档字符串中编写嵌入的 doctests

基本 doctest

Doctest 将成为你的测试工具包中的主要工具。你当然会使用它来进行测试,但也会用于你现在可能不会认为是测试的事情。例如,程序规范和 API 文档都受益于以 doctests 的形式编写并在你的其他测试中一起检查。

与程序源代码一样,doctest 测试是用纯文本编写的。Doctest 提取测试并忽略其余文本,这意味着测试可以嵌入在可读的解释或讨论中。这正是使 doctest 非常适合非经典用途(如程序规范)的功能。

动手实践 – 创建和运行你的第一个 doctest

我们将创建一个简单的 doctest,以演示使用 doctest 的基本原理。

  1. 在你的编辑器中打开一个新的文本文件,并将其命名为test.txt

  2. 将以下文本插入到文件中:

    This is a simple doctest that checks some of Python's arithmetic
    operations.
    
    >>> 2 + 2
    4
    
    >>> 3 * 3
    10
    
  3. 我们现在可以运行 doctest。我们如何运行它的细节取决于我们使用的 Python 版本。在命令提示符下,切换到保存test.txt的目录。

  4. 如果你使用的是 Python 2.6 或更高版本,输入以下命令:

    $ python -m doctest test.txt
    
    
  5. 如果你使用的是 python 2.5 或更低版本,上述命令可能看起来可以工作,但不会产生预期的结果。这是因为 Python 2.6 是第一个在以这种方式调用时在命令行上查找测试文件名的版本。

  6. 如果你使用的是较旧的 Python 版本,你可以通过输入以下命令来运行你的 doctest:

    $ python -c "__import__('doctest').testfile('test.txt')"
    
  7. 当测试运行时,你应该看到如下屏幕所示的输出:动手实践 – 创建和运行你的第一个 doctest

刚才发生了什么?

你编写了一个 doctest 文件,描述了一些算术运算,并执行它来检查 Python 是否像测试所说的那样表现。你是通过告诉 Python 在包含测试的文件上执行 doctest 来运行测试的。

在这种情况下,Python 的行为与测试不同,因为根据测试,三乘三等于十!然而,Python 并不同意这一点。由于 doctest 预期的是一种情况而 Python 做了不同的处理,doctest 向你展示了一个漂亮的错误报告,显示了失败测试的位置以及实际结果与预期结果之间的差异。报告底部是一个总结,显示了每个测试文件中失败的测试数量,当你有多个包含测试的文件时,这很有帮助。

记住,doctest 文件是供计算机和人类消费的。尽量以人类读者容易理解的方式编写测试代码,并添加大量的普通语言注释。

doctests 的语法

你可能已经从查看之前的示例中猜到了:doctest 通过寻找看起来像是从 Python 交互会话中复制粘贴的文本部分来识别测试。任何可以用 Python 表达的内容都可以在 doctest 中使用。

>>> 提示符开头的行会被发送到 Python 解释器。以 ... 提示符开头的行作为上一行代码的延续,允许你将复杂的块语句嵌入到你的 doctests 中。最后,任何不以 >>>... 开头的行,直到下一个空白行或 >>> 提示符,代表从该语句期望得到的输出。输出将像在交互式 Python 会话中一样显示,包括返回值和打印到控制台的内容。如果你没有输出行,doctest 假设该语句在控制台上没有可见的结果。

Doctest 忽略文件中不属于测试的部分,这意味着你可以在测试之间放置解释性文本、HTML、行图或其他任何你喜欢的元素。我们在之前的 doctest 中就利用了这一点,在测试本身之前添加了一个解释性句子。

行动时间 – 编写一个更复杂的测试

我们将编写另一个测试(如果你喜欢,可以将其添加到 test.txt 文件中),展示 doctest 语法的大部分细节。

  1. 将以下文本插入到你的 doctest 文件(test.txt)中,与现有测试至少隔一个空白行:

    Now we're going to take some more of doctest's syntax for a spin.
    
    >>> import sys
    >>> def test_write():
    ...     sys.stdout.write("Hello\n")
    ...     return True
    >>> test_write()
    Hello
    True
    

    想想看:这会做什么?你期望测试通过,还是失败?

  2. 如我们之前讨论的那样,在测试文件上运行 doctest。因为我们添加了新的测试到包含之前测试的同一文件中,所以我们仍然看到通知说三乘三不等于十。现在,尽管如此,我们还看到运行了五个测试,这意味着我们的新测试已经运行并成功。行动时间 – 编写一个更复杂的测试

刚才发生了什么?

就 doctest 而言,我们在文件中添加了三个测试。

  • 第一个测试表示,当我们 import sys 时,不应该有任何可见的操作发生。

  • 第二个测试表示,当我们定义 test_write 函数时,不应该有任何可见的操作发生。

  • 第三个测试说明,当我们调用test_write函数时,HelloTrue应该按顺序出现在控制台上,每行一个。

由于这三个测试都通过了,doctest 对于它们并没有太多要说的。它所做的只是将底部报告的测试数量从两个增加到五个。

预期异常

对于测试预期中的行为是否正常,这当然很好,但同样重要的是要确保当预期失败时,确实会失败。换句话说;有时你的代码应该抛出一个异常,你需要能够编写测试来检查这种行为。

幸运的是,doctest 在处理异常时遵循了几乎与处理其他一切相同的原理;它寻找看起来像 Python 交互会话的文本。这意味着它寻找看起来像 Python 异常报告和回溯的文本,并将其与抛出的任何异常进行匹配。

Doctest 在处理异常方面与其他工具略有不同。它不仅仅精确匹配文本,如果匹配失败则报告失败。异常回溯通常包含许多与测试无关的细节,但这些细节可能会意外地改变。Doctest 通过完全忽略回溯来处理这个问题:它只关心第一行——Traceback (most recent call last)——这告诉它你预期会有一个异常,以及回溯之后的部分,这告诉它你预期哪种异常。只有当这些部分之一不匹配时,Doctest 才会报告失败。

这还有另一个好处:当你编写测试时,手动确定回溯的外观需要大量的努力,而且不会带来任何好处。最好是简单地省略它们。

行动时间——预期异常

这又是你可以添加到test.txt中的另一个测试,这次测试的是应该抛出异常的代码。

  1. 将以下文本插入到你的 doctest 文件中(请注意,由于书籍格式的限制,这段文本的最后一行已被换行,应该是一行):

    Here we use doctest's exception syntax to check that Python is correctly enforcing its grammar.
    
    >>> def faulty():
    ...     yield 5
    ...     return 7
    Traceback (most recent call last):
    SyntaxError: 'return' with argument inside generator (<doctest test.txt[5]>, line 3)
    
  2. 测试应该抛出一个异常,所以如果它没有抛出异常,或者抛出了错误的异常,测试就会失败。请确保你理解这一点:如果测试代码执行成功,那么测试就会失败,因为它预期会有一个异常。

  3. 使用 doctest 运行测试,以下屏幕将显示:行动时间——预期异常

发生了什么?

由于 Python 不允许一个函数同时包含 yield 语句和带有值的 return 语句,因此测试定义这样的函数会导致异常。在这种情况下,异常是一个带有预期值的SyntaxError。因此,doctest 将其视为与预期输出匹配,从而测试通过。在处理异常时,通常希望能够使用通配符匹配机制。Doctest 通过其省略号指令提供这种功能,我们将在后面讨论。

预期输出中有空白行

Doctest 使用第一个空白行来识别预期输出的结束。那么,当预期输出实际上包含空白行时,您该怎么办?

Doctest 通过匹配预期输出中只包含文本<BLANKLINE>的行,与实际输出中的真实空白行进行匹配来处理这种情况。

使用指令控制 doctest

有时,doctest 的默认行为使得编写特定的测试变得不方便。这就是 doctest 指令发挥作用的地方。指令是特殊格式的注释,您将其放置在测试的源代码之后,告诉 doctest 以某种方式更改其默认行为。

指令注释以# doctest:开头,之后跟一个以逗号分隔的选项列表,这些选项可以启用或禁用各种行为。要启用一个行为,写一个+(加号符号)后跟行为名称。要禁用一个行为,写一个(减号符号)后跟行为名称。

忽略部分结果

测试输出中只有一部分实际上与确定测试是否通过相关是很常见的。通过使用+ELLIPSIS指令,您可以使得 doctest 将预期输出中的文本...(称为省略号)视为通配符,这将匹配输出中的任何文本。

当您使用省略号时,doctest 将向前扫描,直到找到与预期输出中省略号之后文本匹配的文本,然后从那里继续匹配。这可能导致意外的结果,例如省略号匹配实际输出中的 0 长度部分,或匹配多行。因此,需要谨慎使用。

行动时间 – 在测试中使用省略号

我们将在几个不同的测试中使用省略号,以更好地了解它的作用和使用方法。

  1. 将以下文本插入到您的 doctest 文件中:

    Next up, we're exploring the ellipsis.
    
    >>> sys.modules # doctest: +ELLIPSIS
    {...'sys': <module 'sys' (built-in)>...}
    
    >>> 'This is an expression that evaluates to a string'
    ... # doctest: +ELLIPSIS
    'This is ... a string'
    >>> 'This is also a string' # doctest: +ELLIPSIS
    'This is ... a string'
    
    >>> import datetime
    >>> datetime.datetime.now().isoformat() # doctest: +ELLIPSIS
        '...-...-...T...:...:...'
    
  2. 使用 doctest 运行测试,以下屏幕显示:.行动时间 – 在测试中使用省略号

  3. 没有省略号,这些测试都不会通过。考虑一下这一点,然后尝试进行一些更改,看看它们是否产生您预期的结果。

发生了什么?

我们刚刚看到了如何启用省略号匹配。此外,我们还看到了 doctest 指令注释可以放置的位置的一些变化,包括单独的块续行符。

我们有机会稍微玩一下省略号,也许看到了为什么应该小心使用。看看最后一个测试。你能想象任何输出不是 ISO 格式的日期时间戳,但它仍然会匹配的情况吗?

忽略空白

有时候,空白(空格、制表符、换行符及其类似物)带来的麻烦比它们的价值要大。也许你希望能够在测试文件中将单个预期输出行的内容拆分成多行,或者也许你正在测试一个使用大量空白但不会提供任何有用信息的系统。

Doctest 提供了一种“标准化”空白的方式,将预期输出和实际输出中的任何空白字符序列都转换为一个空格。然后它会检查这些标准化版本是否匹配。

行动时间 - 标准化空白

我们将编写几个测试来展示空白标准化是如何工作的。

  1. 将以下文本插入到你的 doctest 文件中:

    Next, a demonstration of whitespace normalization.
    
    >>> [1, 2, 3, 4, 5, 6, 7, 8, 9]
    ... # doctest: +NORMALIZE_WHITESPACE
    [1, 2, 3,
     4, 5, 6,
     7, 8, 9]
    
    >>> sys.stdout.write("This text\n contains weird     spacing.")
    ... # doctest: +NORMALIZE_WHITESPACE
    This text contains weird spacing.
    
  2. 使用 doctest 运行测试,以下屏幕将显示:Time for action – normalizing whitespace

  3. 注意,其中一个测试在预期输出中插入额外的空白,而另一个测试则忽略了实际输出中的额外空白。当你使用+NORMALIZE_WHITESPACE时,你会在文本文件中格式化事物方面获得很大的灵活性。

完全跳过一个示例

在某些情况下,doctest 会将一些文本识别为要检查的示例,而实际上你只想让它作为普通文本。这种情况比最初看起来要少,因为通常让 doctest 检查所有它能检查的内容并没有什么坏处。事实上,通常让 doctest 检查所有它能检查的内容是有帮助的。然而,当你想限制 doctest 检查的内容时,可以使用+SKIP指令。

行动时间 - 跳过测试

这是一个跳过测试的例子:

  1. 将以下文本插入到你的 doctest 文件中:

    Now we're telling doctest to skip a test
    
    >>> 'This test would fail.' # doctest: +SKIP
    If it were allowed to run.
    
  2. 使用 doctest 运行测试,以下屏幕将显示:Timedoctest, controllingexample, skipping for action – skipping tests

  3. 注意,测试并没有失败,并且运行测试的数量没有改变。

发生了什么?

跳过指令将原本应该是测试的内容转换成了纯文本(就 doctest 而言)。Doctest 从未运行过这个测试,实际上从未将其计为一个测试。

有几种情况下跳过测试可能是个好主意。有时候,你有一个测试没有通过(你知道它不会通过),但这并不是目前应该解决的问题。使用skip指令让你可以暂时忽略这个测试。有时候,你有一个看起来像测试的文本块,但 doctest 解析器认为它只是供人类阅读的。skip指令可以用来标记这段代码不是实际测试的一部分。

其他 doctest 指令

有许多其他指令可以发出以调整 doctest 的行为。它们在docs.python.org/library/doctest.html#option-flags-and-directives中得到了全面记录,但这里有一个简要概述:

  • +DONT_ACCEPT_TRUE_FOR_1,这使得 doctest 将True1视为不同的值,而不是像通常那样将它们视为匹配。

  • +DONT_ACCEPT_BLANKLINE,这使得 doctest 忽略<BLANKLINE>的特殊含义。

  • +IGNORE_EXCEPTION_DETAIL,这使得 doctest 在异常类型相同的情况下,无论其余的异常是否匹配,都将异常视为匹配。

  • +REPORT_UDIFF,这使得 doctest 在显示失败的测试时使用unified diff格式。如果你习惯于阅读unified diff格式,这很有用,这是开源社区中最常见的 diff 格式。

  • +REPORT_CDIFF,这使得 doctest 在显示失败的测试时使用context diff格式。如果你习惯于阅读context diff格式,这很有用。

  • +REPORT_NDIFF,这使得 doctest 在显示失败的测试时使用ndiff格式。如果你习惯于阅读ndiff格式,这很有用。

  • +REPORT_ONLY_FIRST_FAILURE使得 doctest 在应用后避免打印出失败报告,如果已经打印了失败报告。测试仍然被执行,doctest 仍然跟踪它们是否失败。只有通过使用此标志来更改报告。

执行范围

当 doctest 从文本文件中运行测试时,同一文件中的所有测试都在相同的执行范围内运行。这意味着如果你在一个测试中导入一个模块或绑定一个变量,那么这个模块或变量在后续的测试中仍然可用。我们已经在本章迄今为止编写的测试中多次利用了这个事实:例如,sys模块只导入了一次,尽管它在几个测试中使用。

这种行为并不一定有益,因为测试需要彼此隔离。我们不希望它们相互污染,因为如果一个测试依赖于另一个测试所做的东西,或者如果它因为另一个测试所做的东西而失败,那么这两个测试在某种程度上就变成了一个覆盖更大代码部分的测试。你不想这种情况发生,因为知道哪个测试失败了并不能给你提供太多关于出错原因和出错位置的信息。

那么,我们如何为每个测试提供自己的执行范围呢?有几种方法可以实现。一种方法是将每个测试简单地放在自己的文件中,以及所需的任何解释性文本。这工作得很好,但除非你有工具来查找和运行所有测试,否则运行测试可能会很痛苦。我们稍后会讨论这样一个工具(称为 nose)。

另一种给每个测试自己的执行范围的方法是在函数内定义每个测试,如下所示:

>>> def test1():
...     import frob
...     return frob.hash('qux')
>>> test1()
77

通过这样做,最终在共享作用域中结束的只有测试函数(在这里命名为test1)。frob模块,以及函数内部绑定的任何其他名称,都是隔离的。

第三种方法是在创建名称时要谨慎,并确保在每个测试部分的开始将它们设置为已知值。在许多方面,这是一种最简单的方法,但也是最让你感到负担的方法,因为你必须跟踪作用域中的内容。

为什么 doctest 以这种方式行为,而不是将测试相互隔离?doctest 文件不仅是为了计算机阅读,也是为了人类阅读。它们通常形成一种叙事,从一件事流向另一件事。不断地重复之前的内容会打断叙事。换句话说,这种方法是在文档和测试框架之间的一种折衷,是一种既适合人类也适合计算机的中间地带。

在本书中我们深入研究(简单地称为 unittest)的另一个框架在更正式的层面上工作,并强制执行测试之间的分离。

快速问答——doctest 语法

这些问题没有答案。在 doctest 中尝试你的答案,看看你是否正确!

  1. doctest 如何识别测试表达式的开始?

  2. doctest 如何知道文本表达式的预期输出开始和结束的位置?

  3. 你会如何告诉 doctest 你想要将一个长的预期输出拆分成多行,即使实际的测试输出并不是这样?

  4. 异常报告中哪些部分被 doctest 忽略?

  5. 当你在测试文件中绑定一个变量时,什么代码可以“看到”这个变量?

  6. 我们为什么关心代码可以看到由测试创建的变量?

  7. 我们如何让 doctest 不关心输出部分的内容?

尝试英雄——从英语到 doctest

是时候展翅飞翔了!我将给你一个关于单个函数的描述,用英语。你的任务是把这个描述复制到一个新的文本文件中,然后添加测试,以描述所有要求,让计算机能够理解和检查。

努力使 doctests 不仅仅是为了计算机。好的 doctests 往往也会为人类读者澄清事情。总的来说,这意味着你将它们作为例子呈现给人类读者,穿插在文本中。

不再拖延,以下是英文描述:

The fib(N) function takes a single integer as its only parameter N. If N is 0 or 1, the function returns 1\. If N is less than 0, the function raises a ValueError. Otherwise, the function returns the sum of fib(N – 1) and fib(N – 2). The returned value will never be less than 1\. On versions of Python older than 2.2, and if N is at least 52, the function will raise an OverflowError. A naïve implementation of this function would get very slow as N increased.

我给你一个提示,并指出最后一句——关于函数运行缓慢——实际上并不是可测试的。随着计算机变得越来越快,任何依赖于“慢”的任意定义的测试最终都会失败。此外,没有好的方法来测试一个慢函数和一个陷入无限循环的函数之间的差异,所以尝试这样做是没有意义的。如果你发现自己需要这样做,最好是退一步,尝试不同的解决方案。

注意

计算机科学家称无法判断一个函数是否卡住还是只是运行缓慢为“停机问题”。我们知道除非我们有一天发现一种根本更好的计算机类型,否则这个问题是无法解决的。更快的计算机无法解决这个问题,量子计算机也无法,所以不要抱太大希望!

在 Python 文档字符串中嵌入 doctests

Doctests 并不局限于简单的文本文件。你可以将 doctests 放入 Python 的文档字符串中。

为什么想要这样做呢?有几个原因。首先,文档字符串是 Python 代码可用性的重要部分(但只有当它们讲述真相时)。如果一个函数、方法或模块的行为发生变化,而文档字符串没有更新,那么文档字符串就变成了错误信息,反而成为一种阻碍而不是帮助。如果文档字符串包含几个 doctest 示例,那么可以自动定位过时的文档字符串。将 doctest 示例放入文档字符串的另一个原因是它非常方便。这种做法将测试、文档和代码都放在同一个地方,可以轻松找到。

如果文档字符串成为太多测试的家园,这可能会破坏其作为文档的效用。应避免这种情况;如果你发现自己有太多的测试在文档字符串中,以至于它们不能作为快速参考,那么将大多数测试移动到单独的文件中。

行动时间 – 在文档字符串中嵌入 doctest

我们将直接在测试的 Python 源文件中嵌入一个测试,通过将其放置在文档字符串中来实现。

  1. 创建一个名为 test.py 的文件,内容如下:

    def testable(x):
        r"""
        The `testable` function returns the square root of its
        parameter, or 3, whichever is larger.
        >>> testable(7)
        3.0
        >>> testable(16)
        4.0
        >>> testable(9)
        3.0
        >>> testable(10) == 10 ** 0.5
        True
        """
        if x < 9:
            return 3.0
        return x ** 0.5
    
  2. 在命令提示符下,切换到保存 test.py 的目录,然后通过输入以下命令来运行测试:

    $ python -m doctest test.py
    
    

    注意

    如前所述,如果你有一个较旧的 Python 版本,这对你来说不起作用。相反,你需要输入 python -c "__import__('doctest').testmod(__import__('test'))"

  3. 如果一切正常,你根本不应该看到任何东西。如果你想确认 doctest 正在执行某些操作,请通过将命令更改为来开启详细报告:

    python -m doctest -v test.py
    
    

注意

对于较旧的 Python 版本,请使用 python -c "__import__('doctest').testmod(__import__('test'), verbose=True)"

刚才发生了什么?

你将 doctest 直接放在被测试函数的文档字符串中。这是一个展示用户如何做某事的测试的好地方。它不是一个详细、低级测试的好地方(上面的例子为了说明目的而相当详细,已经接近过于详细),因为文档字符串需要作为 API 文档。你只需回顾一下例子,就可以看到 doctests 占据了文档字符串的大部分空间,而没有告诉读者比单个测试更多的信息。

任何可以作为良好 API 文档的测试都是包含在文档字符串中的良好候选。

注意 docstring 使用了原始字符串(由第一个三引号前的r字符表示)。养成使用原始字符串作为 docstrings 的习惯是个好习惯,因为你通常不希望转义序列(例如\n表示换行)被 Python 解释器解释。你希望它们被当作文本处理,以便它们能正确地传递给 doctest。

Doctest 指令

嵌入的 doctests 可以接受与文本文件中的 doctests 完全相同的指令,使用完全相同的语法。正因为如此,我们之前讨论的所有 doctest 指令也可以用来影响嵌入的 doctests 的评估方式。

执行范围

嵌入在 docstrings 中的 doctests 与文本文件中的 doctests 的执行范围略有不同。doctest 不是为文件中的所有测试提供一个单一的执行范围,而是为每个 docstring 创建一个单一的执行范围。共享同一个 docstring 的所有测试也共享一个执行范围,但它们与其他 docstrings 中的测试是隔离的。

将每个 docstring 独立于其自身的执行范围通常意味着,当它们嵌入在 docstrings 中时,我们不需要过多考虑隔离 doctests。这是幸运的,因为 docstrings 主要是为了文档而设计的,而隔离测试所需的技巧可能会模糊其含义。

将理论付诸实践:AVL 树

我们将逐步介绍使用 doctest 为名为 AVL 树的数据结构创建可测试规范的过程。AVL 树是一种组织键值对的方式,以便可以通过键快速定位它们。换句话说,它非常类似于 Python 内置的字典类型。AVL 这个名字指的是发明这种数据结构的人的姓名首字母。

如其名所示,AVL 树将存储在其中的键组织成一种树结构,每个键最多有两个键——一个键通过比较小于键,另一个则更大。在下面的图片中,键Elephant有两个子键,Goose有一个,而AardvarkFrog都没有。

将理论付诸实践:AVL 树

AVL 树是特殊的,因为它保持树的某一侧不会比另一侧高得多,这意味着用户可以期望它无论在什么情况下都能可靠且高效地执行。在之前的图片中,如果Frog获得一个子键,AVL 树将重新组织以保持平衡。

我们将在这里编写 AVL 树实现的测试,而不是编写实现本身。因此,我们将详细阐述 AVL 树是如何工作的细节,以便查看它正常工作时应做什么。

注意

如果你想了解更多关于 AVL 树的信息,你将在互联网上找到许多很好的参考资料。关于这个主题的维基百科条目是一个很好的起点:en.wikipedia.org/wiki/AVL_tree

我们将从一份普通语言规范开始,然后在段落之间插入测试。

小贴士

你不必真的将这些内容全部输入到文本文件中;这里是为了让你阅读和思考。它也包含在这本书的代码下载中。

英文规范

第一步是用普通语言描述期望的结果。这可能是一些你自己做的事情,或者别人为你做的事情。如果你在为别人工作,希望你和你的雇主可以坐下来一起解决这个问题。

在这种情况下,没有太多需要解决的问题,因为 AVL 树已经描述了几十年。即便如此,这里的描述并不完全像你在其他地方能找到的。这种歧义性正是为什么纯语言规范不够好的原因。我们需要一个明确的规范,这正是 doctest 文件中的测试可以提供的。

以下文本将放入一个名为AVL.txt的文件中(你可以在附带的代码存档中找到其最终形式。在这个处理阶段,该文件只包含普通语言规范):

An AVL Tree consists of a collection of nodes organized in a binary tree structure. Each node has left and right children, each of which may be either None or another tree node. Each node has a key, which must be comparable via the less-than operator. Each node has a value. Each node also has a height number, measuring how far the node is from being a leaf of the tree -- a node with height 0 is a leaf.

The binary tree structure is maintained in ordered form, meaning that of a node's two children, the left child has a key that compares less than the node's key and the right child has a key that compares greater than the node's key.

The binary tree structure is maintained in a balanced form, meaning that for any given node, the heights of its children are either the same or only differ by 1.

The node constructor takes either a pair of parameters representing a key and a value, or a dict object representing the key-value pairs with which to initialize a new tree.

The following methods target the node on which they are called, and can be considered part of the internal mechanism of the tree:

Each node has a recalculate_height method, which correctly sets the height number.

Each node has a make_deletable method, which exchanges the positions of the node and one of its leaf descendants, such that the the tree ordering of the nodes remains correct.

Each node has rotate_clockwise and rotate_counterclockwise methods. Rotate_clockwise takes the node's right child and places it where the node was, making the node into the left child of its own former child. Other nodes in the vicinity are moved so as to maintain the tree ordering. The opposite operation is performed by rotate_counterclockwise.

Each node has a locate method, taking a key as a parameter, which searches the node and its descendants for a node with the specified key, and either returns that node or raises a KeyError.

The following methods target the whole tree rooted at the current node. The intent is that they will be called on the root node:

Each node has a get method taking a key as a parameter, which locates the value associated with the specified key and returns it, or raises KeyError if the key is not associated with any value in the tree.

Each node has a set method taking a key and a value as parameters, and associating the key and value within the tree.

Each node has a remove method taking a key as a parameter, and removing the key and its associated value from the tree. It raises KeyError if no values was associated with that key.

节点数据

规范的前三段描述了 AVL 树节点的成员变量,并告诉我们变量的有效值。它们还告诉我们如何测量树的高度,并定义了平衡树的意义。现在我们的任务是吸收这些想法,并将它们编码成计算机最终可以用来检查我们代码的测试。

我们可以通过创建一个节点并测试其值来检查这些规范,但这实际上只是对构造函数的测试。测试构造函数是很重要的,但我们真正想要做的是将检查节点变量是否处于有效状态的检查纳入到我们对每个成员函数的测试中。

为了达到这个目的,我们将定义一个我们的测试可以调用的函数来检查节点的状态是否有效。我们将在第三段之后定义这个函数:

注意

注意,这个测试被编写成好像 AVL 树实现已经存在。它试图导入一个包含AVL类的avl_tree模块,并试图以特定的方式使用AVL类。当然,目前并没有avl_tree模块,所以测试会失败。这正是应该发生的。失败仅仅意味着,当真正需要实现树的时候,我们应该在一个名为avl_tree的模块中实现,其内容应该符合我们的测试假设。这样测试的一个好处是能够在编写代码之前就测试代码。

>>> from avl_tree import AVL

>>> def valid_state(node):
...     if node is None:
...         return
...     if node.left is not None:
...         assert isinstance(node.left, AVL)
...         assert node.left.key < node.key
...         left_height = node.left.height + 1
...     else:
...         left_height = 0
...
...     if node.right is not None:
...         assert isinstance(node.right, AVL)
...         assert node.right.key > node.key
...         right_height = node.right.height + 1
...     else:
...         right_height = 0
...
...     assert abs(left_height - right_height) < 2
...     node.key < node.key
...     node.value

>>> def valid_tree(node):
...     if node is None:
...         return
...     valid_state(node)
...     valid_tree(node.left)
...     valid_tree(node.right)

注意,我们实际上还没有调用那些函数。它们本身不是测试,而是我们将用来简化编写测试的工具。我们在这里定义它们,而不是在我们要测试的 Python 模块中定义,因为它们在概念上不是测试代码的一部分,并且任何阅读测试的人都需要能够看到辅助函数的作用。

构造函数

第四段描述了 AVL 节点的构造函数:节点构造函数接受一对表示键和值的参数,或者一个表示要初始化新树的键值对的dict对象。

构造函数有两种可能的操作模式:

  • 它可以创建一个初始化的单个节点

  • 或者它可以创建并初始化一个整个节点树。对于单个节点模式的测试很简单:

    >>> valid_state(AVL(2, 'Testing is fun'))
    

构造函数的另一种模式是一个问题,因为它几乎肯定将通过创建一个初始树节点然后调用其 set 方法来添加其余节点来实现。为什么那是一个问题?因为我们不想在这里测试 set 方法:这个测试应该完全集中在构造函数是否正确工作,当它所依赖的一切都正常工作时

提示

换句话说,测试应该能够假设除了正在测试的具体代码块之外的所有内容都工作正常。

然而,这并不总是有效的假设。那么,我们如何为调用测试之外代码的事情编写测试?

对于这个问题,我们将在第四章中了解到解决方案。现在,我们只需将构造函数的第二种操作模式留待测试。

重新计算高度

recalculate_height方法在第 5 段中描述。

为了测试它,我们需要一个树来操作,我们不想使用构造函数的第二种模式来创建它。毕竟,那种模式还没有经过测试,即使它经过了测试,我们也希望这个测试与它独立。我们更愿意使测试完全独立于构造函数,但在这个情况下,我们需要对规则做出一个小小的例外(因为在不以某种方式调用其构造函数的情况下创建对象是困难的)。

我们将定义一个函数来构建一个特定的树并返回它。这个函数将在我们后面的几个测试中也很有用。使用这个函数,测试recalculate_height将变得容易。

>>> def make_test_tree():
...     root = AVL(7, 'seven')
...     root.height = 2
...     root.left = AVL(3, 'three')
...     root.left.height = 1
...     root.left.right = AVL(4, 'four')
...     root.right = AVL(10, 'ten')
...     return root

>>> tree = make_test_tree()
>>> tree.height = 0
>>> tree.recalculate_height()
>>> tree.height
2

make_test_tree函数通过手动构建其每个部分并将其连接成一个类似这样的结构来构建一个树:

重新计算高度

可删除

您不能删除有子节点的节点,因为这会使节点的小孩与树的其他部分断开连接。如果我们从树的底部删除Elephant节点,那么AardvarkGooseFrog怎么办?如果我们删除Goose,之后如何找到Frog

可删除

解决这个问题的方法是让节点与其左侧最大的叶子子节点交换位置(或者右侧最小的叶子子节点,但我们不会那样做)。

我们将通过使用之前定义的 make_test_tree 函数来创建一个新的树来工作,并检查 make_deletable 是否正确交换:

Each node has a make_deletable method, which exchanges the positions of the node and one of its leaf descendants, such that the the tree ordering of the nodes remains correct.

>>> tree = make_test_tree()
>>> target = tree.make_deletable()
>>> (tree.value, tree.height)
('four', 2)
>>> (target.value, target.height)
('seven', 0)

注意

这里需要注意的一点是,make_deletable 函数不应该删除它被调用的节点。它应该将节点移动到一个可以安全删除的位置。它必须在不违反定义 AVL 树结构的任何约束的情况下进行这种树的重组织。

旋转

两个旋转函数在树中执行一些相当复杂的链接操作。你可能发现它们在普通语言描述中的操作有些令人困惑。在这些情况下,一点点的代码比任何数量的句子都要有意义得多。

虽然树旋转通常是通过重新排列树中节点之间的链接来定义的,但我们会通过查看值(而不是直接查看左右链接)来检查它是否成功。这允许实现者在需要时交换节点的内容——而不是节点本身。毕竟,对于规范来说,哪个操作发生并不重要,所以我们不应该排除一个完全合理的实现选择。

旋转测试代码的第一部分只是创建一个树并验证它看起来是否符合我们的预期:

>>> tree = make_test_tree()
>>> tree.value
'seven'
>>> tree.left.value
'three'

一旦我们有一个可以工作的树,我们就尝试旋转操作,并检查结果是否看起来应该是这样的:

>>> tree.rotate_counterclockwise()
>>> tree.value
'three'
>>> tree.left
None
>>> tree.right.value
'seven'
>>> tree.right.left.value
'four'
>>> tree.right.right.value
'ten'
>>> tree.right.left.value
'four'
>>> tree.left is None
True

最后,我们以相反的方向旋转,并检查最终结果是否与原始树相同,正如我们所期望的那样:

>>> tree.rotate_clockwise()
>>> tree.value
'seven'
>>> tree.left.value
'three'
>>> tree.left.right.value
'four'
>>> tree.right.value
'ten'
>>> tree.right.left is None
True
>>> tree.left.left is None
True

定位一个节点

locate 方法预期返回一个节点,或者根据键值是否存在于树中而抛出 KeyError 异常。我们将再次使用我们特别构建的树,这样我们就能确切知道树的结构。

>>> tree = make_test_tree()
>>> tree.locate(4).value
'four'
>>> tree.locate(17) # doctest: +ELLIPSIS
Traceback (most recent call last):
KeyError: …

locate 方法旨在通过键值来简化插入、删除和查找操作,但它不是一个高级接口。它返回一个节点对象,因为如果你有一个能为你找到正确节点的函数,实现高级操作就很容易了。

测试其余的规范

就像构造函数的第二种模式一样,测试其余的规范涉及到测试依赖于自身之外的事物的代码,我们将在第四章中介绍。

摘要

我们学习了 doctest 的语法,并探讨了几个示例,描述了如何使用它。之后,我们针对 AVL 树的实际规范进行了研究,探讨了如何将其形式化为一系列 doctests,以便我们可以用它来自动检查实现的正确性。

具体来说,我们涵盖了 doctest 的默认语法,以及如何修改它的指令,如何在文本文件中编写 doctests,如何在 Python 文档字符串中编写 doctests,以及使用 doctest 将规范转换为测试的感觉。

现在我们已经了解了 doctest,我们准备讨论如何使用 doctest 进行单元测试——这是下一章的主题。

第三章:使用 Doctest 进行单元测试

好的,我们已经讨论了 doctest 的功能以及如何让它按我们的意愿运行。我们也讨论了使用 doctest 进行测试。那么,在这一章中还有什么要讨论的呢?在这一章中,我们将讨论称为单元测试的编程纪律。我们仍然会使用 doctest,但这次的重点是你在做什么以及为什么,而不是如何做的细节。

在这一章中,我们将:

  • 详细讨论单元测试是什么

  • 讨论单元测试如何帮助开发各个阶段

  • 使用示例来说明单元测试及其优势

那么,让我们开始吧!

单元测试是什么,它不是什么?

本节的标题又提出了另一个问题:“我为什么要关心?”一个答案是,单元测试是一种最佳实践,它在编程存在的大部分时间里一直在演变。另一个答案是,单元测试的核心原则只是常识;对我们整个社区来说,我们花了这么长时间才认识到它们,这实际上可能有点尴尬。

好的,那么什么是单元测试?在其最基本的形式中,单元测试可以被定义为以这样的方式测试代码的最小有意义的部分(这样的部分被称为单元),即每个部分的成功或失败只取决于它自己。在很大程度上,我们已经在遵循这个原则了。

这一定义中的每一部分都有其原因:我们测试最小的有意义的代码部分,因为当测试失败时,我们希望这个失败尽可能具体地告诉我们问题所在。我们使每个测试独立,因为我们不希望一个测试在它应该失败时让另一个测试成功;或者在一个测试应该成功时让它失败。当测试不独立时,你不能信任它们告诉你你需要知道的信息。

传统上,自动化测试与单元测试相关联。自动化测试使得运行单元测试变得快速且容易,而这些单元测试往往易于自动化。我们肯定会大量使用自动化测试,包括 doctest,以及之后的 unittest 和 Nose 等工具。

任何涉及多个单元的测试自动就不是单元测试。这很重要,因为这类测试的结果往往令人困惑。不同单元的效果交织在一起,最终结果是,你不仅不知道问题出在哪里(是这段代码中的错误,还是只是对其他代码的错误输入做出了正确的响应?),而且你通常也不确定具体的问题是什么——这个输出是错误的,但每个单元是如何导致错误的?经验科学家必须进行实验,每次只检查一个假设,无论研究对象是化学、物理还是程序代码的行为。

行动时间——识别单元

想象一下,你负责测试以下代码:

class testable:
    def method1(self, number):
        number += 4
        number **= 0.5
        number *= 7
        return number

    def method2(self, number):
        return ((number * 2) ** 1.27) * 0.3

    def method3(self, number):
        return self.method1(number) + self.method2(number)

    def method4(self):
        return 1.713 * self.method3(id(self))
  1. 在这个例子中,单位是什么?整个班级是一个单独的单位,还是每种方法是一个单独的单位。每个语句或每个表达式又如何呢?请记住,单位的定义是相对主观的(尽管永远不会大于单个班级),并做出自己的决定。

  2. 考虑你的选择。如果你选择了不同的选择,会有什么后果?例如,如果你选择将每个方法视为一个单位,如果你选择将整个类作为单位处理,会有什么不同?

  3. 考虑method4。它的结果依赖于所有其他方法正确工作。除此之外,它还依赖于从一个测试运行到另一个测试运行发生变化的东西,即self对象的唯一 ID。在自包含测试中将method4作为单位是否甚至可能?如果我们可以改变除method4之外的一切,我们还需要改变什么才能使method4在自包含测试中运行并产生可预测的结果?

刚才发生了什么?

通过回答这三个问题,你思考了单元测试的一些更深入的方面。

单位构成的问题对于你如何组织你的测试是基本的。语言的能力影响这个选择。例如,C++和 Java 使得将方法作为单位处理变得困难或不可能,因此在这些语言中,每个类通常被视为一个单独的单位。另一方面,C 语言根本不支持类作为语言特性,因此显然的单位选择是函数。Python 足够灵活,既可以认为类或方法作为单位,当然它也有独立的函数,这些函数也自然地被认为是单位。Python 无法轻松地将函数或方法内的单个语句作为单位处理,因为当测试运行时,它们并不作为单独的对象存在。它们都被合并成一个单一的代码对象,这是函数的一部分。

你选择单位的结果是深远的。单位越小,测试通常越有用,因为它们可以更快地缩小错误的位置和性质。例如,选择将可测试的类作为单个单位的一个后果是,如果任何方法中存在错误,类的测试将失败。这告诉你存在错误,但并不是(例如)在method2中。另一方面,将method4及其类似方法作为单位处理涉及一定程度的繁琐,以至于本书的下一章专门用于处理这种情况。即便如此,我建议大多数时候使用方法和函数作为单位,因为从长远来看这是值得的。

在回答第三个问题时,你可能发现函数idself.method3需要有不同的定义,这些定义会产生可预测的结果,并且在不调用任何其他单元的代码的情况下完成。在 Python 中,用这种临时代替品替换真实函数相对容易,但在下一章中我们将讨论一种更结构化的方法。

突击测验——理解单位

考虑以下代码,然后尝试回答问题:

class class_one:
    def __init__(self, arg1, arg2):
        self.arg1 = int(arg1)
        self.arg2 = arg2

    def method1(self, x):
        return x * self.arg1

    def method2(self, x):
        return self.method1(self.arg2) * x
  1. 假设方法就是单元,上述代码中存在多少个单元?

  2. 哪些单元假设其他单元的正确运行?换句话说,哪些单元不是独立的?

  3. 你需要做什么来创建一个对其他单元独立的method2测试?

开发过程中的单元测试

我们将逐步介绍一个单一类的开发,对待它就像对待一个真实项目一样。我们将严格确保将单元测试集成到项目的每个阶段。这有时可能看起来很愚蠢,但请配合一下。从经验中我们可以学到很多东西。

我们将要处理的例子是一个 PID 控制器。基本思想是 PID 控制器是控制现实世界硬件的反馈回路。它从可以测量硬件某些属性的传感器获取输入,并生成一个控制信号来调整该属性以达到某个期望的状态。在工厂中,机器人手臂的位置可能由 PID 控制器控制。

注意

如果你想了解更多关于PID控制器的信息,互联网上充满了相关信息。维基百科条目是一个很好的起点:en.wikipedia.org/wiki/PID_controller

设计阶段

我们的概念客户向我们提出了以下(相当稀疏的)规范:

We want a class that implements a PID controller for a single variable. The measurement, setpoint, and output should all be real numbers.

We need to be able to adjust the setpoint at runtime, but we want it to have a memory, so that we can easily return to the previous setpoint.

行动时间——设计阶段的单元测试

是时候将那个规范变得更加正式和完整,通过编写描述所需行为的单元测试来实现。

  1. 我们需要编写一个测试来描述 PID 构造函数。在检查我们的参考后,我们确定 PID 控制器由三个增益和一个设定点定义。控制器有三个组成部分:比例、积分和微分(因此得名 PID)。每个增益是一个数字,它决定了控制器三个部分中的哪一个对最终结果的影响程度。设定点决定了控制器的目标;换句话说,它试图将控制变量移动到哪个位置。考虑到所有这些,我们决定构造函数应该只存储增益设定点,以及初始化一些内部状态,我们知道我们将来会因为阅读 PID 控制器的工作原理而需要这些状态:

    >>> import pid
    
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0)
    
    >>> controller.gains
    (0.5, 0.5, 0.5)
    >>> controller.setpoint
    [0.0]
    >>> controller.previous_time is None
    True
    >>> controller.previous_error
    0.0
    >>> controller.integrated_error
    0.0
    
  2. 我们需要编写描述测量处理的测试。这是控制器在起作用,它将测量值作为输入,并产生一个控制信号,该信号应该平滑地将测量变量移动到setpoint。为了正确工作,我们需要能够控制控制器看到的是当前时间。之后,我们将我们的测试输入值插入到定义 PID 控制器的数学中,包括gains,以确定正确的输出应该是什么:

    >>> import time
    >>> real_time = time.time
    >>> time.time = (float(x) for x in xrange(1, 1000)).next
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0)
    >>> controller.measure(12)
    -6.0
    >>> controller.measure(6)
    -3.0
    >>> controller.measure(3)
    -4.5
    >>> controller.measure(-1.5)
    -0.75
    >>> controller.measure(-2.25)
    -1.125
    >>> time.time = real_time
    
  3. 我们需要编写描述setpoint处理的测试。我们的客户要求一个setpoint堆栈,所以我们编写了检查这种堆栈行为的测试。编写使用这种堆栈行为的代码使我们注意到,没有setpoint的 PID 控制器不是一个有意义的实体,因此我们添加了一个测试来检查 PID 类通过抛出异常来拒绝这种情况。

    >>> pid = reload(pid)
    >>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0)
    
    >>> controller.push_setpoint(7)
    >>> controller.setpoint
    [0.0, 7.0]
    
    >>> controller.push_setpoint(8.5)
    >>> controller.setpoint
    [0.0, 7.0, 8.5]
    
    >>> controller.pop_setpoint()
    8.5
    >>> controller.setpoint
    [0.0, 7.0]
    
    >>> controller.pop_setpoint()
    7.0
    >>> controller.setpoint
    [0.0]
    
    >>> controller.pop_setpoint()
    Traceback (most recent call last):
    ValueError: PID controller must have a setpoint
    
    

刚才发生了什么?

我们的客户给了我们一个相当好的初始规格说明,但它留下了很多细节需要假设。通过编写这些测试,我们已经明确地定义了我们的目标。编写测试迫使我们明确我们的假设。此外,我们还得到了一个使用对象的机会,这让我们对这个对象的理解比这个阶段更难获得。

通常我们会将 doctests 放在与规格说明相同的文件中,实际上你会在书籍的代码存档中找到这些内容。在书籍格式中,我们使用了规格说明文本作为每个示例步骤的描述。

你可能会问,对于每个规格说明的部分,我们应该编写多少个测试。毕竟,每个测试都是为了特定的输入值,所以当代码通过它时,它所证明的只是代码为那个特定的输入产生了正确的结果。代码可能实际上做了一些完全错误的事情,但仍然可以通过测试。事实上,通常可以安全地假设你将要测试的代码原本应该是做正确的事情,因此针对每个指定的属性进行单个测试,可以很好地区分出正常和异常的代码。再加上对任何指定的边界值进行的测试——对于“X 输入值可能在 1 和 7 之间,包括 1 和 7”的情况,你可能需要添加对 X 值为 0.9 和 7.1 的测试,以确保它们不会被接受——这样你就做得很好了。

我们使用了一些技巧来使测试可重复和独立。在第一个测试之后,我们在pid模块上调用reload函数,从磁盘重新加载它。这会重置模块中可能发生变化的任何内容,并导致它重新导入它所依赖的任何模块。后者的效果尤为重要,因为在测量测试中,我们用虚拟函数替换了time.time。我们想确保pid模块使用虚拟时间函数,所以我们重新加载pid模块。如果使用真实的时间函数而不是虚拟函数,测试将没有用处,因为历史上只有一个时间点它会成功。测试需要可重复。

虚拟时间函数是通过创建一个迭代器,从 1 到 999(以浮点值形式)计数,并将time.time绑定到该迭代器的next方法来创建的。一旦我们完成了与时间相关的测试,我们就替换了原始的time.time

现在,我们有了一个模块的测试,而这个模块并不存在。这很好!编写测试比编写模块要容易,这为我们快速、轻松地编写正确的模块提供了一个垫脚石。一般来说,你总是希望在编写测试的代码之前就准备好测试。

快速问答 – 设计过程中的单元测试

  1. 当我们测试的代码是虚构的,而且测试甚至无法运行时,我们为什么关心测试是否相互独立呢?

  2. 为什么作为程序员,你在这一阶段编写测试?这应该是编写规范的人的工作内容吗?

  3. 这一阶段的测试试图使用尚未编写的代码,因此它们在某种程度上定义了那段代码。这有什么优点和缺点?

尝试一下英雄

尝试自己这样做几次:描述一些你希望在现实生活中能够访问的程序或模块,使用正常语言。然后回到它上面,尝试编写测试,描述程序或模块。注意那些编写测试让你意识到先前描述中的模糊性,或者让你意识到有更好的做事方式的地方。

开发阶段

拥有测试在手,我们准备编写一些代码。测试将作为我们的指南,一个主动告诉我们何时出错的规定。

行动时间 - 开发过程中的单元测试

  1. 第一步是运行测试。当然,我们相当清楚将要发生什么;它们都会失败。然而,了解确切的失败原因是有用的,因为那些是我们需要通过编写代码来解决的问题。行动时间 – 开发过程中的单元测试

    之后还有更多失败的测试,但你应该已经明白了这个概念。

  2. 从测试和我们对 PID 控制器的参考资料中汲取灵感,我们编写了pid.py模块:

    from time import time
    
    class PID:
        def __init__(self, P, I, D, setpoint):
            self.gains = (float(P), float(I), float(D))
            self.setpoint = [float(setpoint)]
            self.previous_time = None
            self.previous_error = 0.0
            self.integrated_error = 0.0
    
        def push_setpoint(self, target):
            self.setpoint.append(float(target))
    
        def pop_setpoint(self):
            if len(self.setpoint) > 1:
                return self.setpoint.pop()
            raise ValueError('PID controller must have a setpoint')
    
        def measure(self, value):
            now = time()
            P, I, D = self.gains
    
            err = value - self.setpoint[-1]
    
            result = P * err
            if self.previous_time is not None:
                delta = now - self.previous_time
                self.integrated_error +q= err * delta
                result += I * self.integrated_error
                result += D * (err - self.previous_error) / delta
    
            self.previous_error = err
            self.previous_time = now
    
            return result
    
  3. 接下来我们再次运行测试。我们希望它们都能通过,但不幸的是,测量方法似乎有一些 bug。行动时间 - 开发中的单元测试

    还有几个报告显示了类似的情况(总共应该有五个测试失败)。测量函数正在反向工作,当它应该返回负数时却返回了正数,反之亦然。

  4. 我们知道需要在测量方法中寻找符号错误,所以我们不太难找到并修复这个错误。在measure方法的第四行,应该从设定点减去测量值,而不是反过来:

            err = self.setpoint[-1] – value
    

    修复了那个问题之后,我们发现所有测试都通过了。

刚才发生了什么?

我们使用测试来告诉我们需要做什么,以及何时我们的代码完成。我们第一次运行测试给我们列出了一些需要编写的事情;类似于待办事项列表。在我们编写了一些代码之后,我们再次运行测试以查看它是否按预期工作,这给了我们一个新的待办事项列表。我们继续在运行测试和编写代码之间交替,直到所有测试都通过。当所有测试都通过时,要么我们就完成了,要么我们需要编写更多的测试。

每当我们发现一个测试尚未捕获的 bug 时,正确的做法是添加一个测试来捕获它,然后修复它。这样,你不仅修复了 bug,还添加了一个测试,覆盖了之前未测试的程序的一些方面。这个测试可能会在未来捕获其他 bug,或者告诉你你是否意外地重新引入了原始 bug。

这种“测试一点,编码一点”的编程风格被称为测试驱动开发,你会发现它非常高效。

注意到测试失败的模式立即就显现出来了。当然,这并不能保证总是如此,但这种情况相当普遍。结合能够将注意力集中在有问题的特定单元上,调试通常很容易完成。

另一件需要考虑的事情是测试隔离。PID类的使用方法依赖于存储在self中的变量,这意味着为了使测试隔离,我们必须确保任何方法对self变量的更改都不会传播到任何其他方法。我们通过仅重新加载pid模块并为每个测试创建一个新的PID类实例来实现这一点。只要测试(以及被测试的代码)不调用self上的任何其他方法,这就足够了。

反馈阶段

因此,我们有一个 PID 控制器,并且它通过了所有的测试。我们感觉相当不错。是时候勇敢面对狮子,向客户展示它了!

幸运的是,大部分人都喜欢这样。尽管他们也有一些要求:他们希望我们允许他们可选地指定当前时间作为measure的参数,而不是仅仅使用time.time来计算。他们还希望我们更改构造函数的签名,使其接受初始测量值和可选时间作为参数。最后,他们希望我们将measure函数重命名为calculate_response,因为他们认为这更清楚地描述了它的功能。

行动时间 - 反馈期间的单元测试

那么,我们该如何处理这个问题呢?程序通过了所有测试,但测试不再反映需求。

  1. 在构造函数测试中添加初始参数,并更新预期结果。

  2. 添加第二个构造函数测试,该测试测试现在预期作为构造函数一部分的可选时间参数。

  3. 将所有测试中的measure方法名称更改为calculate_response

  4. calculate_response测试中添加初始构造函数参数 - 在我们做这件事的时候,我们注意到这将改变calculate_response函数的行为。我们联系客户进行澄清,他们决定这是可以接受的,因此我们更新期望值以匹配更改后我们应该计算会发生的事情。

  5. 添加第二个calculate_response测试,该测试检查在提供可选时间参数时的行为。

  6. 在进行所有这些更改后,我们的规范/测试文件看起来如下。更改或添加的行格式不同,以便您更容易找到它们。

    We want a class that implements a PID controller for a single variable. The measurement, setpoint, and output should all be real numbers. The constructor should accept an initial measurement value in addition to the gains and setpoint.
    
    >>> import time
    >>> real_time = time.time
    >>> time.time = (float(x) for x in xrange(1, 1000)).next
    >>> import pid
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0,
    ...                      initial=12)
    >>> controller.gains
    (0.5, 0.5, 0.5)
    >>> controller.setpoint
    [0.0]
    >>> controller.previous_time
    1.0
    >>> controller.previous_error
    -12.0
    >>> controller.integrated_error
    0.0
    >>> time.time = real_time
    
    The constructor should also optionally accept a parameter specifying when the initial measurement was taken.
    
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=1,
    ...                      initial=12, when=43)
    >>> controller.gains
    (0.5, 0.5, 0.5)
    >>> controller.setpoint
    [1.0]
    >>> controller.previous_time
    43.0
    >>> controller.previous_error
    -11.0
    >>> controller.integrated_error
    0.0
    
    >>> real_time = time.time
    >>> time.time = (float(x) for x in xrange(1, 1000)).next
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0,
    ...                      initial=12)
    >>> controller.calculate_response(6)
    -3.0
    >>> controller.calculate_response(3)
    -4.5
    >>> controller.calculate_response(-1.5)
    -0.75
    >>> controller.calculate_response(-2.25)
    -1.125
    >>> time.time = real_time
    
    The calculate_response method should be willing to accept a parameter specifying at what time the call is happening.
    
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0,
    ...                      initial=12, when=1)
    >>> controller.calculate_response(6, 2)
    -3.0
    >>> controller.calculate_response(3, 3)
    -4.5
    >>> controller.calculate_response(-1.5, 4)
    -0.75
    >>> controller.calculate_response(-2.25, 5)
    -1.125
    
    We need to be able to adjust the setpoint at runtime, but we want it to have a memory, so that we can easily return to the previous setpoint.
    
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0,
    ...                      initial=12)
    >>> controller.push_setpoint(7)
    >>> controller.setpoint
    [0.0, 7.0]
    >>> controller.push_setpoint(8.5)
    >>> controller.setpoint
    [0.0, 7.0, 8.5]
    >>> controller.pop_setpoint()
    8.5
    >>> controller.setpoint
    [0.0, 7.0]
    >>> controller.pop_setpoint()
    7.0
    >>> controller.setpoint
    [0.0]
    >>> controller.pop_setpoint()
    Traceback (most recent call last):
    ValueError: PID controller must have a setpoint
    
    

刚才发生了什么?

我们的测试不再符合要求,因此它们不得不进行更改。

好吧,但我们不希望它们改变太多,因为我们的测试集合帮助我们避免代码中的回归。回归是导致曾经工作过的某些东西停止工作的更改。避免回归的最好方法之一是避免删除测试。如果您仍然有检查每个期望行为和每个已修复错误的测试,那么如果您引入了回归,您会立即发现。

这是我们添加新测试以检查提供可选时间参数时的行为的一个原因。另一个原因是,如果我们将这些参数添加到现有测试中,我们就不会有测试来检查不使用这些参数会发生什么。我们总是想检查每个单元的每个代码路径。

有时候,一个测试就不再正确了。例如,使用measure方法的测试完全是错误的,需要更新为调用calculate_response。当我们更改这些测试时,尽管我们尽量保持更改最小,因为毕竟我们不希望测试停止检查仍然正确的行为,也不希望在测试本身中引入错误。

initial 参数添加到构造函数中是一个大事件。这不仅改变了构造函数应有的行为方式,还以相当戏剧性的方式改变了 calculate_response(原名 measure)方法的行为。由于这是一个正确行为的改变(我们直到测试指出这一点才意识到这一点,而这反过来又使我们能够在编写代码之前从客户那里确认正确的行为应该是什么),我们别无选择,只能修改测试,重新计算预期的输出。然而,做所有这些工作除了未来能够检查函数是否正确工作之外,还有一个好处;它使得我们在实际编写代码时更容易理解函数应该如何工作。

回到开发阶段

好吧,现在是时候回到开发阶段了。在现实生活中,我们无法预测我们多久需要来回循环于开发和反馈之间,但我们希望保持循环尽可能短。我们来回切换得越频繁,就越能接触到客户真正想要的东西,这会使工作更加高效、更有成就感。

行动时间 – 开发中的单元测试... 再次

我们已经更新了测试,所以现在是我们回到所有测试都通过的状态的时候了。

  1. 首先,让我们运行测试,以便得到需要完成的新事项列表。行动时间 – 开发中的单元测试... 再次

    当然,之后还有更多的错误报告。Doctest 报告了总共 32 个失败的例子,尽管这并不特别有意义,因为目前没有一个测试能够构建一个 PID 对象。修复那个构造函数可能是一个合理的起点。

  2. 以 doctest 报告作为指南,我们着手调整 PID 类。这将最好作为一个迭代过程,我们进行一些更改,然后运行测试,然后进行一些更改,如此等等。然而,最终我们将会得到以下类似的结果(push_setpointpop_setpoint 方法没有改变,所以为了节省空间,这里省略了它们):

    from time import time
    
    class PID:
        def __init__(self, P, I, D, setpoint, initial, when=None):
            self.gains = (float(P), float(I), float(D))
            self.setpoint = [float(setpoint)]
    
            if when is None:
                self.previous_time = time()
            else:
                self.previous_time = float(when)
    
            self.previous_error = self.setpoint[-1] - float(initial)
            self.integrated_error = 0.0
    
        def calculate_response(self, value, now=None):
            if now is None:
                now = time()
            else:
                now = float(now)
    
            P, I, D = self.gains
            err = self.setpoint[-1] - value
            result = P * err
            delta = now - self.previous_time
            self.integrated_error += err * delta
            result += I * self.integrated_error
            result += D * (err - self.previous_error) / delta
    
            self.previous_error = err
            self.previous_time = now
    
            return result
    

    我们再次检查测试,并且它们都通过了。

刚才发生了什么?

这与我们第一次通过开发阶段并没有太大区别。就像之前一样,我们有一套测试,那些测试的错误报告给我们列出了一项需要修复的事项清单。在我们工作的过程中,我们留意需要测试但尚未测试的事项,并添加那些测试。当所有测试都通过时,我们再次与客户核对(这意味着我们回到了反馈阶段)。最终客户会满意。然后我们可以继续发布代码,然后进入维护阶段。

在我们工作的过程中,测试以快速、简便的方式让我们了解我们所做的是否有效,以及我们进展到了什么程度。这使得我们很容易看到我们编写的代码做了些什么,这反过来又使得编码过程更加流畅,甚至更加有趣。编写只是坐那的代码是无聊且容易出错的,但因为我们有测试,我们的代码不会只是坐那。它是活跃的,我们可以在任何时候看到结果。

维护阶段

现在我们已经将我们的工作转交给客户,我们必须确保他们对此感到满意。这意味着修复任何可能在我们测试中遗漏的错误(希望不多)并根据要求进行小的改进。

行动时间 - 维护期间的单元测试

我们的客户向我们提出了一个变更请求:他们不希望PID类在其构造函数中接受负增益值,因为负增益会使输出将事物推得更远,而不是将其拉向设定点。

  1. 我们添加了新的测试,描述了当将负增益传递给构造函数时应该发生什么。我们正在测试旧测试没有描述的内容,因此我们可以保留旧测试不变,只需添加新测试。这是一件好事,因为它意味着旧测试将确保捕捉到我们在进行这项工作时可能引入的任何回归。

    It's important that the P, I and D gains not be negative.
    
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=-0.5, I=0.5, D=0.5, setpoint=0,
    ...                      initial=12)
    Traceback (most recent call last):
    ValueError: PID controller gains must be non-negative
    
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=-0.5, D=0.5, setpoint=0,
    ...                      initial=12)
    Traceback (most recent call last):
    ValueError: PID controller gains must be non-negative
    
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=0.5, D=-0.5, setpoint=0,
    ...                      initial=12)
    Traceback (most recent call last):
    ValueError: PID controller gains must be non-negative
    
  2. 运行测试以查看需要做什么。正如我们可能预期的那样,在这个案例中,doctest 报告了三个失败,每个都是针对我们刚刚添加的测试——PID类没有抛出预期的ValueError异常。行动时间 - 维护期间的单元测试

  3. 现在我们编写代码,使PID类通过测试。这很容易通过在构造函数中添加以下内容来完成:

    if P < 0 or I < 0 or D < 0:
      raise ValueError('PID controller gains must be non-negative')
    
  4. 我们再次运行测试,当它们全部通过时,我们可以向客户报告变更已经实施。

    注意

    记住,如果 doctest 没有打印任何内容,那么所有测试都通过了。它只会告诉你关于错误的信息,除非你在其命令行上传递了-v

发生了什么?

这看起来相当直接,但事实是,我们的测试集在这里帮了我们大忙。当我们在一个代码库中摸索,试图更新其行为,或者修复我们从未考虑过可能存在的错误时,很容易破坏程序的其它部分。特别是当代码库是我们已经有一段时间没有合作的项目时,这种情况在维护请求中很常见。多亏了我们编写的测试中存储的专业知识,我们不必担心忘记构成正确行为或代码各部分可能出错的具体细节。当我们回到代码时,我们不必浪费时间或精力重新学习这些细节。相反,我们只需执行测试。

我们的客户可能并不了解我们的测试流程,但他们很欣赏我们因此能提供的快速响应时间。

重用阶段

最终,会有这样一个时刻——如果我们所编写的代码是有用的,我们将在不同的项目中再次使用它。这意味着我们将把它放在一个可能不再有效的假设的上下文中。

行动时间 - 重复使用时的单元测试

我们的客户想在新的项目中使用 PID 控制器,但有一个转折:将要测量和控制的价值表示为复数。当我们编写 PID 控制器时,有一个隐含的假设,即这些值总是可以表示为浮点数。为了重新使用这段代码,我们该怎么办?让我们来看看。

注意

顺便说一句,如果你不知道复数是什么,不要担心。它们实际上并不复杂;复数只是一个坐标对,就像纬度和经度一样。

  1. 编写一些使用复数setpointinitial和测量的测试。由于我们想确保不会破坏仍然使用浮点数的代码,我们并没有替换旧的测试,只是添加了更多的测试。

    注意

    你会注意到我们在这里使用了一些看起来非常随机的数字。它们根本不是随机的。复数可以被认为是表示坐标;它们表示的是我们在早期测试中使用过的相同值,只是旋转了45度并平移了1+1j。例如,之前我们使用的是值12,现在我们使用的是12 * complex(cos(0.25 * pi), sin(0.25 * pi))+ (1+1j),即9.4852813742385695+9.4852813742385695j。如果你不理解,或者不在乎,只要知道相同的表达式可以用来计算这个例子中每个复数的值就足够了:只需将适当的数字替换12即可。你可以在math模块中找到sincospi

    (这里的一些输入行非常长,必须折行以适应页面。它们不应该在 doctest 文件中折行。)

    We want to be able to use complex numbers as the measurement and setpoint for the PID controller.
    
    >>> pid = reload(pid)
    >>> controller = pid.PID(P=0.5, I=0.5, D=0.5,
    ...      setpoint = 1 + 1j,
    ...      initial = 9.4852813742385695+9.4852813742385695j,
    ...      when = 1)
    >>> controller.calculate_response(5.2426406871192848+5.2426406871192848j, 2)
    (-2.1213203435596424-2.1213203435596424j)
    
    >>> controller.calculate_response(3.1213203435596424+3.1213203435596424j, 3)
    (-3.1819805153394638-3.1819805153394638j)
    
    >>> controller.calculate_response(-0.060660171779821193-0.060660171779821193j, 4)
    (-0.53033008588991093-0.53033008588991093j)
    
    >>> controller.calculate_response(-0.5909902576697319-0.5909902576697319j, 5)
    (-0.79549512883486606-0.79549512883486606j)
    
  2. 好吧,正确的行为已经被计算出来,测试也已经编写。让我们运行它们,看看什么不工作。我们运行了 doctests,首先出现的是在构造函数中抛出的异常。看起来我们的浮点假设已经引起了麻烦。之后还有几个错误报告,但由于构造函数没有工作,我们无法期望它们有太多意义。行动时间 - 重复使用时的单元测试

  3. 构造函数中的问题源于将复数传递给float类的构造函数,这是不允许的。我们真的需要在那个地方调用float吗?有时我们确实需要,因为我们不想用整数来表示setpointinitial。在 Python 3.0 之前的版本中,整数除法与浮点除法的工作方式不同,所以整数可能会严重破坏系统的行为。

    因此,我们希望在initialsetpoint上调用float构造函数,除非它们是复数。这使得构造函数看起来像这样(再次注意长行的包装):

    def __init__(self, P, I, D, setpoint, initial, when=None):
        self.gains = (float(P), float(I), float(D))
    
        if P < 0 or I < 0 or D < 0:
            raise ValueError('PID controller gains must be non-negative')
    
        if not isinstance(setpoint, complex):
            setpoint = float(setpoint)
    
        if not isinstance(initial, complex):
            initial = float(initial)
    
        self.setpoint = [setpoint]
    
        if when is None:
            self.previous_time = time()
        else:
            self.previous_time = float(when)
    
        self.previous_error = self.setpoint[-1] - initial
        self.integrated_error = 0.0
    
  4. 好吧,我们已经修复了构造函数。我们再次运行测试,所有的测试都通过了!也许有些令人惊讶的是,calculate_response函数已经与复数兼容。

刚才发生了什么?

我们最初编写测试帮助我们确定我们正在做出的假设,并且测试会明确检查这些假设。此外,即使我们不知道我们正在做出的假设,它们也有被我们的测试检查的倾向,因为它们隐含在我们的期望中。这个例子是测试期望的浮点数结果。如果我们完全从构造函数中移除了对 float 的调用,所有期望浮点数的测试都会失败,这会告诉我们我们违反了一个关于代码行为的隐含假设。

我们的测试让我们对我们的代码正确性有信心(即使它在操作复数时),并且我们没有通过更改代码破坏其他东西。没有麻烦,没有烦恼;它工作得很好。如果有一个测试失败了,那会告诉我们问题所在。无论如何,我们知道我们在项目中的位置以及下一步需要做什么,这让我们能够使过程继续进行。

快速问答 - 单元测试

  1. 当你编写测试时,你应该在参考被测试的代码的同时进行,还是应该基于你对正确行为应该是什么的期望进行,在代码编写之前?

  2. 对或错:尽可能避免更改或删除测试,当无法保持测试不变时,更倾向于更改它们而不是删除它们。

  3. 你认为你的测试应该多久运行一次?你能想到执行测试的特别好的时间吗?

  4. 如果你的开发过程是测试驱动的,作为程序员,你将花大部分时间做什么?

尝试一下英雄式测试驱动开发

尝试使用本章中我们讨论的方法来实现这个简单的语言规范:

The library consists of three classes, one representing bank accounts, one representing people, and one representing monetary transactions. Person objects should be able to draw on zero or more accounts, and account objects should be accessible to one or more people. Transactions should represent the transfer of a certain amount of money between one person and another, by transferring the money from an account accessible by the first person to an account accessible by the second.
Attempts to create invalid transactions should fail.
After having been created, it should be possible to execute a transaction to perform the actual transfer between accounts.
All monies should be represented as fixed point numbers, not floating point.

摘要

我们在本章中学到了很多关于单元测试和测试驱动开发的知识,这些是快速构建可靠程序的最佳实践学科。

具体来说,我们涵盖了单元测试的定义,单元测试如何帮助开发过程的每个阶段,使用单元测试驱动开发的感觉,以及它如何使过程更快、更愉快。

现在我们已经了解了单元测试,我们准备讨论如何通过使用模拟对象来使测试的隔离更加容易,这是下一章的主题。

第四章:通过使用模拟对象来打破紧密耦合

在前面的几个章节中,我们多次遇到需要确保单位不会相互污染测试的情况。现在,我们将探讨如何正式处理这些情况——模拟对象——以及一个特定的模拟对象工具包,即 Python Mocker。

在本章中,我们将:

  • 检查模拟对象的一般概念

  • 学习如何使用 Python Mocker

  • 学习如何模拟方法中的“self”参数

所以,让我们开始吧!

安装 Python Mocker

第一次,我们使用了一个不在标准 Python 发行版中的工具。这意味着我们需要下载并安装它。

现在是安装 Python Mocker 的时候了

  1. 在撰写本文时,Python Mocker 的主页位于 labix.org/mocker,而其下载文件托管在 launchpad.net/mocker/+download。请继续下载最新版本,我们将讨论如何安装它。

  2. 首先需要做的事情是将下载的文件解压。它是一个 .tar.bz2 文件,对于 Unix、Linux 或 OSX 用户来说应该可以正常工作。Windows 用户需要第三方程序(7-Zip 工作得很好:www.7-zip.org/)来解压存档。将解压后的文件存储在某个临时位置。

  3. 一旦你将文件解压到某个位置,通过命令行进入该位置。现在,为了进行下一步,你需要允许将文件写入你的 Python 安装目录的 site-packages 中(如果你是第一个安装 Python 的人,那么你就有这个权限)或者你需要使用 Python 2.6 或更高版本。

  4. 如果你能够写入 site-packages,请输入

    $ python setup.py install 
    
    
  5. 如果你不能写入 site-packages,但你在使用 Python 2.6 或更高版本,请输入

    $ python setup.py install --user
    
    

注意

有时候,一个名为 easy_install 的工具可以简化 Python 模块和包的安装过程。如果你想尝试,请根据页面上的说明下载并安装 setuptoolspypi.python.org/pypi/setuptools,然后运行命令 easy_install mocker。一旦这个命令完成,你应该就可以使用 Nose 了。

一旦你成功运行了安装程序,Python Mocker 就可以使用了。

模拟对象的概念

在这个意义上,“Mock”意味着“模仿”,这正是模拟对象所做的事情。模拟对象模仿构成你程序的真实对象,但实际上并不是这些对象,也不以任何方式依赖它们。

模拟对象不会执行真实对象会执行的操作,而是执行预定义的简单操作,这些操作看起来像是真实对象应该执行的操作。这意味着它的方法返回适当的值(你告诉它返回的值)或引发适当的异常(你告诉它引发的异常)。模拟对象就像一只模仿鸟;模仿其他鸟的叫声,而不理解它们。

在我们之前的工作中,我们已经使用了一个模拟对象,当时我们用返回一个递增序列的数字的对象(在 Python 中,函数是对象)替换了time.time。这个模拟对象就像time.time一样,除了它总是返回相同的序列,无论我们何时运行测试或运行测试的计算机有多快。换句话说,它将我们的测试与外部变量解耦。

这就是模拟对象的核心:将测试与外部变量解耦。有时这些变量是外部时间或处理器速度等东西,但通常变量是其他单元的行为。

Python Mocker

这个想法相当直接,但只需看一下上一章中time.time的模拟版本,就可以看出,如果不使用某种工具包,创建模拟对象可能是一个复杂且令人烦恼的过程,并且可能会干扰测试的可读性。这就是 Python Mocker(或根据个人喜好选择的其他几个模拟对象工具包)发挥作用的地方。

行动时间——探索 Mocker 的基础

我们将介绍 Mocker 的一些最简单——也是最实用——的功能。为此,我们将编写测试来描述一个代表特定数学运算(乘法)的类,该运算可以应用于任意其他数学运算对象的值。换句话说,我们将处理电子表格程序(或类似的东西)的内部结构。

我们将使用模拟器来创建模拟对象,以替代真实的操作对象。

  1. 创建一个文本文件来保存测试,并在开头添加以下内容(假设所有数学运算都将定义在名为operations的模块中):

    >>> from mocker import Mocker
    >>> import operations
    
  2. 我们决定每个数学运算类都应该有一个接受代表新对象操作数的对象的构造函数。它还应该有一个接受变量绑定字典作为参数的evaluate函数,并返回一个数字作为结果。我们可以很容易地编写构造函数的测试,所以我们首先做这件事(注意,我们在测试文件中包含了一些说明,这总是一个好主意):

    We're going to test out the constructor for the multiply operation, first. Since all that the constructor has to do is record all of the operands, this is straightforward.
    
    >>> mocker = Mocker()
    >>> p1 = mocker.mock()
    >>> p2 = mocker.mock()
    >>> mocker.replay()
    >>> m = operations.multiply(p1, p2)
    >>> m.operands == (p1, p2)
    True
    >>> mocker.restore()
    >>> mocker.verify()
    
  3. 对于evaluate方法的测试要复杂一些,因为我们需要测试几个方面。这也是我们看到 Mocker 真正优势的开始:

    Now we're going to check the evaluate method for the multiply operation. It should raise a ValueError if there are less than two operands, it should call the evaluate methods of all operations that are operands of the multiply, and of course it should return the correct value.
    
    >>> mocker = Mocker()
    >>> p1 = mocker.mock()
    >>> p1.evaluate({}) #doctest: +ELLIPSIS
    <mocker.Mock object at ...>
    >>> mocker.result(97.43)
    
    >>> mocker.replay()
    
    >>> m = operations.multiply(p1)
    >>> m.evaluate({})
    Traceback (most recent call last):
    ValueError: multiply without at least two operands is meaningless
    
    >>> mocker.restore()
    >>> mocker.verify()
    
    >>> mocker = Mocker()
    >>> p1 = mocker.mock()
    
    >>> p1.evaluate({}) #doctest: +ELLIPSIS
    <mocker.Mock object at ...>
    >>> mocker.result(97.43)
    >>> p2 = mocker.mock()
    >>> p2.evaluate({}) #doctest: +ELLIPSIS
    <mocker.Mock object at ...>
    >>> mocker.result(-16.25)
    
    >>> mocker.replay()
    
    >>> m = operations.multiply(p1, p2)
    >>> round(m.evaluate({}), 2)
    -1583.24
    
    >>> mocker.restore()
    >>> mocker.verify()
    
  4. 如果我们现在运行测试,我们会得到一个失败测试的列表。其中大部分是由于 Mocker 无法导入operations模块造成的,但列表的底部应该看起来像这样:探索 Mocker 基础 - 行动时间

  5. 最后,我们将在operations模块中编写一些通过这些测试的代码,生成以下结果:

    class multiply:
        def __init__(self, *operands):
            self.operands = operands
    
        def evaluate(self, bindings):
            vals = [x.evaluate(bindings) for x in self.operands]
            if len(vals) < 2:
                raise ValueError('multiply without at least two '                     
                                 'operands is meaningless')
    
            result = 1.0
            for val in vals:
                result *= val
            return result
    
  6. 现在我们运行测试时,不应该有任何测试失败。

刚才发生了什么?

为类似这样的东西编写测试的难度在于(正如通常所做的那样)需要将乘法类与所有其他数学运算类解耦,以便乘法测试的结果仅取决于乘法是否正确工作。

我们通过使用 Mocker 框架为模拟对象解决问题。Mocker 的工作方式是,你首先通过做一些像mocker = Mocker()这样的操作来创建一个代表模拟上下文的对象。模拟上下文将帮助你创建模拟对象,并存储有关你期望它们如何被使用的相关信息。此外,它可以帮助你暂时用模拟对象替换库对象(就像我们之前用time.time所做的那样),并在完成后将真实对象恢复到它们的位置。我们稍后会看到更多关于如何做到这一点的内容。

一旦你有了模拟上下文,你通过调用它的mock方法来创建一个模拟对象,然后你演示你期望如何使用模拟对象。模拟上下文记录你的演示,因此当你稍后调用它的replay方法时,它知道每个对象期望的用法以及如何响应。你的测试(使用模拟对象而不是它们所模仿的真实对象),在调用replay之后进行。

最后,在运行测试代码之后,你调用模拟上下文的restore方法来撤销任何库对象的替换,然后调用verify来检查模拟的实际使用是否符合预期。

我们对 Mocker 的第一个使用很简单。我们测试了我们的构造函数,它被指定为极其简单。它不应该对其参数做任何事情,除了将它们存储起来以备后用。当参数甚至不应该做任何事情时,我们使用 Mocker 创建模拟对象作为参数使用,我们真的从中获得了任何好处吗?实际上,我们确实获得了。因为我们没有告诉 Mocker 期望与模拟对象有任何交互,所以在verify步骤中,它将几乎任何参数的使用(存储它们不算,因为存储它们实际上并没有与它们交互)报告为错误。当我们调用mocker.verify()时,Mocker 回顾参数的实际使用情况,如果我们的构造函数试图对它们执行某些操作,它将报告失败。这是将我们的期望嵌入到测试中的另一种方式。

我们再次使用了 Mocker 两次,但在那些后续的使用中,我们告诉 Mocker 期望在模拟对象(即p1p2)上调用evaluate方法,并且期望每个模拟对象的evaluate调用参数为空字典。对于每个我们告诉它期望的调用,我们也告诉它其响应应该是返回一个特定的浮点数。这不是巧合,这模仿了操作对象的行为,我们可以在测试multiply.evaluate时使用这些模拟。

如果multiply.evaluate没有调用模拟的evaluate方法,或者如果它多次调用了其中的一个,我们的mocker.verify调用就会提醒我们问题。这种不仅能够描述应该调用什么,而且能够描述每个东西应该调用多少次的能力是一个非常有用的工具,它使得我们描述期望的内容更加完整。当multiply.evaluate调用模拟的evaluate方法时,返回的值是我们指定的值,因此我们知道multiply.evaluate应该做什么。我们可以彻底测试它,而且我们可以不涉及代码中的任何其他单元。尝试改变multiply.evaluate的工作方式,看看mocker.verify对此有何说法。

模拟函数

正常对象(即通过实例化类创建具有方法和属性的对象)并不是唯一可以模拟的对象。函数是另一种可以模拟的对象,而且实际上做起来相当简单。

在你的演示过程中,如果你想用一个模拟对象来表示一个函数,只需调用它。模拟对象会识别出你希望它表现得像一个函数,并且会记录下你传递给它的参数,以便在测试过程中进行比较。

例如,以下代码创建了一个名为func的模拟,它假装是一个函数,当用参数56hello调用一次时,返回数字11。示例的第二部分使用模拟进行了一个非常简单的测试:

>>> from mocker import Mocker
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(56, "hello") # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(11)

>>> mocker.replay()
>>> func(56, "hello")
11
>>> mocker.restore()
>>> mocker.verify()

模拟容器

容器是另一类特殊对象,也可以进行模拟。就像函数一样,容器可以通过在示例中使用模拟对象作为容器来模拟。

模拟对象能够理解涉及以下容器操作的示例:查找成员、设置成员、删除成员、查找长度以及获取成员的迭代器。根据 Mocker 的版本,可能也支持通过in运算符进行成员资格测试。

在以下示例中,展示了上述所有功能,但为了与不支持这些功能的 Mocker 版本兼容,禁用了in测试。请记住,尽管我们在调用replay之后,名为container的对象看起来像一个实际的容器,但它并不是。它只是在按照我们告诉它的方式响应我们告诉它期待的所有刺激。这就是为什么当我们的测试请求迭代器时,它返回None。这正是我们告诉它的,而且这就是它所知道的一切。

>>> from mocker import Mocker

>>> mocker = Mocker()
>>> container = mocker.mock()

>>> container['hi'] = 18

>>> container['hi'] # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(18)

>>> len(container)
0
>>> mocker.result(1)

>>> 'hi' in container # doctest: +SKIP
True
>>> mocker.result(True)

>>> iter(container) # doctest: +ELLIPSIS
<...>
>>> mocker.result(None)

>>> del container['hi']
>>> mocker.result(None)

>>> mocker.replay()

>>> container['hi'] = 18

>>> container['hi']
18

>>> len(container)
1

>>> 'hi' in container # doctest: +SKIP
True

>>> for key in container:
...     print key
Traceback (most recent call last):
TypeError: iter() returned non-iterator of type 'NoneType'

>>> del container['hi']

>>> mocker.restore()
>>> mocker.verify()

在上述示例中需要注意的一点是,在初始阶段,一些演示(例如,调用len)并没有返回我们预期的mocker.Mock对象。对于某些操作,Python 强制要求结果必须是特定类型(例如,容器长度必须是整数),这迫使 Mocker 打破其常规模式。它不是返回一个通用的 mock 对象,而是返回正确类型的对象,尽管返回对象的值没有意义。幸运的是,这仅在初始阶段适用,当你向 Mocker 展示预期内容时,并且仅在少数情况下发生,所以通常不是什么大问题。尽管如此,有时返回的 mock 对象是必需的,因此了解这些例外情况是值得的。

参数匹配

有时,我们希望我们的 mock 函数和方法接受整个参数域,而不是仅限于接受我们特别告知它的参数所比较的对象。这可能有多种原因:也许 mock 需要接受一个外部变量作为参数(例如当前时间或可用磁盘空间),或者 mock 示例将被多次调用(我们很快会讨论这一点),或者参数对于正确行为的定义可能并不重要。

我们可以通过使用ANYARGSKWARGSISINCONTAINSMATCH这些特殊值来告诉 mock 函数接受参数域,这些特殊值都在mocker模块中定义。这些特殊值在演示阶段(在你调用replay之前)作为函数调用参数传递给 mock 对象。

ANY

ANY作为函数参数传递会导致对象接受在该位置上的任意单个对象作为其参数。


>>> from mocker import Mocker, ANY
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, ANY) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.replay()
>>> func(7, 'this could be anything')
5
>>> mocker.restore()
>>> mocker.verify()

ARGS

ARGS作为函数参数传递会导致对象接受任意数量的位置参数,就像它在参数列表中声明了*args一样。

>>> from mocker import Mocker, ARGS
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, ARGS) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.replay()
>>> func(7, 'this could be anything', 'so could this', 99.2)
5
>>> mocker.restore()
>>> mocker.verify()

KWARGS

KWARGS作为函数参数传递会导致对象接受任意数量的关键字参数,就像它在参数列表中声明了**kwargs一样。

>>> from mocker import Mocker, KWARGS
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, KWARGS) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.replay()
>>> func(7, a='this could be anything', b='so could this')
5
>>> mocker.restore()
>>> mocker.verify()

IS

传递 IS(some_object) 是不寻常的,因为它不是一个不精确的参数,而是比默认值更精确。Mocker 通常接受任何在初始阶段传递的值 == 相等的参数,但如果你使用 IS,它将检查参数和 some_object 是否确实是同一个对象,并且只有当它们是同一个对象时才接受调用。

>>> from mocker import Mocker, IS
>>> mocker = Mocker()
>>> param = [1, 2, 3]
>>> func = mocker.mock()
>>> func(7, IS(param)) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.replay()
>>> func(7, param) # func(7, [1, 2, 3]) would fail
5
>>> mocker.restore()
>>> mocker.verify()

IN

传递 IN(some_container) 会导致 Mocker 接受任何包含在名为 some_container 的容器对象中的参数。


>>> from mocker import Mocker, IN
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, IN([45, 68, 19])) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> func(7, IN([45, 68, 19])) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> func(7, IN([45, 68, 19])) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.replay()
>>> func(7, 19)
5
>>> func(7, 19)
5
>>> func(7, 45)
5
>>> mocker.restore()
>>> mocker.verify()

CONTAINS

传递 CONTAINS(some_object) 会导致 Mocker 接受任何满足 some_object in parameterTrue 的参数。

>>> from mocker import Mocker, CONTAINS
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, CONTAINS(45)) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.replay()
>>> func(7, [12, 31, 45, 18])
5
>>> mocker.restore()
>>> mocker.verify()

MATCH

最后,如果你以上所有方法都无法描述你想要 Mocker 接受参数作为匹配其期望的条件,你可以传递 MATCH(test_function)test_function 应该是一个带有一个参数的函数,当模拟函数被调用时,该参数将被传递给接收到的参数。如果 test_function 返回 True,则参数被接受。

>>> from mocker import Mocker, MATCH
>>> def is_odd(val):
...     return val % 2 == 1
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, MATCH(is_odd)) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.replay()
>>> func(7, 1001)
5
>>> mocker.restore()
>>> mocker.verify()

模拟复杂表达式

能够将 Mocker 模拟对象支持的各项操作结合起来会很好。简单的属性访问、容器成员访问和方法调用构成了大多数对象交互,但它们通常以组合的形式使用,例如 container[index].attribute.method()。我们可以逐步编写一个与这个类似的演示,使用我们已知的关于 Mocker 模拟对象的知识,但能够直接以我们期望的方式在实际代码中编写示例会更好。

幸运的是,我们通常可以做到这一点。在本章之前的所有示例中,你都看到了返回 <mocker.Mock object at ...> 的表达式。这些返回值是模拟对象,就像你通过调用 Mocker.mock 创建的对象一样,它们可以用相同的方式进行使用。这意味着,只要复杂表达式的一部分在演示过程中返回模拟对象,你就可以继续将复杂表达式的更多部分链接到它上面。例如,对于 container[index].attribute.method()container[index] 返回一个模拟对象,对该对象的属性访问返回另一个模拟对象,然后我们调用该对象的方法。方法调用也返回一个模拟对象,但为了正确演示我们的期望,我们不需要对它做任何事情。

Mocker 记录了我们的使用演示,无论它有多复杂,或者我们如何深入嵌套对象。在之后我们调用 replay 之后,它会检查使用情况是否符合我们描述的,即使是非常复杂的用法模式。

尝试一下英雄

尝试告诉 Mocker 期望一个返回字符串的函数调用,该字符串随后被去除空白并按逗号分割,并且作为一个单一复杂表达式来完成。

返回迭代器

到目前为止,我们一直在调用Mocker.result来告诉 Mocker,评估特定示例表达式的结果应该是某个特定的值。这对于模拟大多数表达式来说很棒,并且也涵盖了函数和方法的一般用法,但它并不能真正模拟生成器或其他返回迭代器的函数。为了处理这种情况,我们调用Mocker.generate而不是Mocker.result,如下所示:

>>> from mocker import Mocker
>>> from itertools import islice
>>> mocker = Mocker()
>>> generator = mocker.mock()
>>> generator(12) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.generate([16, 31, 24, 'hike'])

>>> mocker.replay()
>>> tuple(islice(generator(12), 1, 2))
(31,)
>>> mocker.restore()
>>> mocker.verify()

抛出异常

一些表达式会抛出异常而不是返回结果,因此我们需要能够使我们的模拟对象做同样的事情。幸运的是,这并不困难:您调用Mocker.throw来告诉 Mocker,对期望表达式的正确响应是抛出特定的异常。

>>> from mocker import Mocker
>>> mocker = Mocker()
>>> obj = mocker.mock()
>>> obj.thingy # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.throw(AttributeError('thingy does not exist'))

>>> mocker.replay()
>>> obj.thingy
Traceback (most recent call last):
AttributeError: thingy does not exist
>>> mocker.restore()
>>> mocker.verify()

通过模拟调用函数

有时我们模拟的函数有对我们测试很重要的副作用。Mocker 通过允许您指定在特定表达式发生时应调用的一个或多个函数来处理这些情况。这些函数可以是来自您代码库某处的现有函数,或者可以是您专门嵌入到测试中以产生所需副作用的特殊函数。

在与 Mocker 的某个模拟对象交互后,可以调用的函数有一个限制:这样的函数不能需要任何参数。这并不像您想象的那么大,因为您知道应该传递给调用函数的确切参数,因此您可以编写一个小的包装函数,只需用这些参数调用目标函数。这将在下一个示例中演示。

注意

Python 的lambda关键字是一种将单个表达式包装成函数的机制。当函数被调用时,表达式将被评估,并且返回表达式评估的结果。lambda的使用多种多样,但用它来创建围绕其他函数调用的微小包装器是一种常见的用法。

以这种方式调用函数并不排除模拟函数返回结果。在下面的示例中,模拟函数执行两次函数调用并返回数字 5。

>>> from mocker import Mocker
>>> from sys import stdout
>>> mocker = Mocker()
>>> obj = mocker.mock()
>>> obj.method() # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.call((lambda: stdout.write('hi')))
>>> mocker.call((lambda: stdout.write('yo\n')))
>>> mocker.result(5)

>>> mocker.replay()

>>> obj.method()
hiyo
5

>>> mocker.restore()
>>> mocker.verify()

指定一个期望应该发生多次

如您可能在一些先前的示例中注意到的那样,有时告诉 Mocker 预期什么可能会变得重复。IN参数匹配器的例子很好地展示了这一点:我们做了很多重复的工作,告诉 Mocker 我们期望调用func函数三次。这使得测试变得很长(这降低了其可读性),并且违反了编程中的 DRY(不要重复自己)原则,使得后续修改测试变得更加困难。此外,编写所有那些重复的预期也很烦人。

为了解决这个问题,Mocker 允许我们指定在测试执行期间期望发生的次数。我们通过调用Mocker.count来指定期望的重复次数。为了看到最简单的方法,让我们重新编写IN示例,这样我们就不必反复重复:

>>> from mocker import Mocker, IN
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, IN([45, 68, 19])) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.count(3)

>>> mocker.replay()
>>> func(7, 19)
5
>>> func(7, 19)
5
>>> func(7, 45)
5
>>> mocker.restore()
>>> mocker.verify()

注意参数匹配如何与指定计数很好地工作,让我们可以将几个不同的func调用压缩成一个期望,即使它们有不同的参数。通过结合使用这两个特性,模拟的期望通常可以显著缩短,删除冗余信息。但请记住,你不想从测试中删除重要信息;如果第一个调用func的参数是19很重要,或者调用以特定的顺序到来,以这种方式压缩期望会丢失这些信息,这会损害测试。

在上面的例子中,我们指定了期望func调用重复的确切次数,但count比这更灵活。通过提供两个参数,count可以被告知期望在最小和最大次数之间的任何次数重复。只要测试期间的实际重复次数至少与最小次数一样多,并且不超过最大次数,Mocker 就会接受它作为正确的使用方式。

>>> from mocker import Mocker, IN
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, IN([45, 68, 19])) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.count(1, 3)

>>> mocker.replay()
>>> func(7, 19)
5
>>> func(7, 45)
5
>>> func(7, 19)
5
>>> mocker.restore()
>>> mocker.verify()

最后,可以指定期望至少重复一定次数,但没有最大重复次数。只要期望至少满足指定的次数,Mocker 就会认为其使用是正确的。为此,我们在调用count时将None作为最大参数传递。

>>> from mocker import Mocker, IN
>>> mocker = Mocker()
>>> func = mocker.mock()
>>> func(7, IN([45, 68, 19])) # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(5)
>>> mocker.count(1, None)

>>> mocker.replay()
>>> [func(7, 19) for x in range(50)] == [5] * 50
True
>>> mocker.restore()
>>> mocker.verify()

注意

最后一个例子使用了几个 Python 的晦涩特性。在==的左侧是一个“列表推导”,这是一种将另一个可迭代对象转换为列表的紧凑方式。在右侧是列表乘法,它创建一个新列表,包含旧列表成员的重复次数——在这种情况下,列表包含50次重复的值5

用模拟替换库对象

几次,我们都看到了需要用模拟对象替换我们代码之外的东西的需求:例如,需要将time.time替换为能够产生可预测结果的东西,以便我们的 PID 控制器测试有意义。

Mocker 为我们提供了一个工具来满足这种常见需求,并且使用起来相当简单。Mocker 的模拟上下文包含一个名为replace的方法,从我们的角度来看,它几乎与mock一样,但能够完全用模拟对象替换现有对象,无论它存在于哪个模块(或模块),或者何时导入。更好的是,当我们调用restore时,模拟对象消失,原始对象返回到其正确的位置。

这为我们提供了一种简单的方法,即使是从我们通常无法控制的库代码中,也可以隔离我们的测试,并且在完成后不留任何痕迹。

为了说明replace函数,我们将临时将time.time替换为一个模拟对象。我们之前已经这样做过——在我们的 PID 测试中——以一种临时的方式。这使得我们的测试变得丑陋且难以阅读。此外,它只替换了名称time.time为我们的模拟对象:如果我们已经在 PID 代码中执行了from time import time,除非在导入 PID 之前进行替换,否则替换不会捕获它。Mocker 将正确处理这种复杂的替换,无论导入发生的时间或形式如何,而无需我们额外努力。

>>> from time import time
>>> from mocker import Mocker

>>> mocker = Mocker()
>>> mock_time = mocker.replace('time.time')

>>> mock_time() # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(1.3)

>>> mock_time() # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(2.7)

>>> mock_time() # doctest: +ELLIPSIS
<mocker.Mock object at ...>
>>> mocker.result(3.12)

>>> mocker.replay()
>>> '%1.3g' % time()
'1.3'
>>> '%1.3g' % time()
'2.7'
>>> '%1.3g' % time()
'3.12'
>>> mocker.restore()
>>> mocker.verify()

注意,我们在用模拟对象替换它之前导入了time,然而当我们实际使用它时,它竟然是我们使用的模拟对象。在恢复调用之后,如果我们再次调用time,它将再次是真实的时间函数。

注意

为什么我们在time的输出上使用字符串格式化?我们这样做是因为浮点数是不精确的,这意味着我们输入的 3.12,例如,可能在系统中表示为 3.1200000000000001 或其他非常接近但不是精确的 3.12 的值。确切值可能因系统而异,因此比较浮点数会使你的测试更不便携。我们的字符串格式化将数字四舍五入到相关数字。

快速问答——Mocker 使用

  1. 以下哪个选项可以用来检查传递给模拟对象的参数是否是一组允许的参数之一:CONTAINSINIS

  2. 当你指定一个期望可以重复时,你如何指定没有上限,即它可以重复多少次?

  3. mocker.verify()函数的作用是什么?

尝试一下英雄——模拟 datetime

查看以下测试代码,并填写缺失的 Mocker 演示,以便测试通过:

>>> from datetime import datetime
>>> from mocker import Mocker
>>> mocker = Mocker()

Here's where your Mocker demonstrations should go.

>>> mocker.replay()
>>> now = datetime.now()
>>> then = now.replace(hour = 12)
>>> then.isocalendar()
(2009, 24, 3)
>>> then.isoformat()
'2009-06-10T12:30:39.812555'
>>> mocker.restore()
>>> mocker.verify()

模拟 self

当调用一个对象的方法时,它的第一个参数是包含该方法的对象的引用。我们希望能够用模拟对象替换它,因为这是唯一真正分离每个方法的方法,以便每个方法都可以作为一个单独的单元进行测试。如果我们不能模拟self,方法将通过它们包含的对象相互交互,从而倾向于干扰彼此的测试。

所有这些中的难点在于,当调用一个方法时,调用者并没有明确传递self对象:Python 已经知道哪个对象的方法被绑定,并自动填充它。我们如何替换一个不来自我们的参数的模拟对象?

我们可以通过在测试函数的类中找到我们要测试的函数并直接调用它来解决此问题,而不是将其作为绑定到对象的函数调用。这样,我们可以传递所有参数,包括第一个参数,而解释器不会执行任何魔法。

行动时间——传递模拟对象作为 self

  1. 记得我们使用的 testable 类,其中之一是用来演示如何很难将方法分离,以便我们可以将它们作为单元来处理?尽管我们在第三章中已经看到了这一点,但这里再次提到:

    class testable:
        def method1(self, number):
            number += 4
            number **= 0.5
            number *= 7
            return number
    
        def method2(self, number):
            return ((number * 2) ** 1.27) * 0.3
    
        def method3(self, number):
            return self.method1(number) + self.method2(number)
    
        def method4(self):
            return self.method3(id(self))
    
  2. 我们将编写一个针对 method3 的单元测试。像所有单元测试一样,它需要不涉及任何其他单元的代码,在这种情况下意味着 self.method1self.method2 需要是模拟对象。实现这一点最好的方法是将 self 本身变成一个模拟对象,所以这就是我们要做的。第一步是创建一个期望 method3 应该执行的交互的模拟对象:

    >>> from testable import testable
    >>> from mocker import Mocker
    >>> mocker = Mocker()
    
    >>> target = mocker.mock()
    >>> target.method1(12) # doctest: +ELLIPSIS
    <mocker.Mock object at ...>
    >>> mocker.result(5)
    >>> target.method2(12) # doctest: +ELLIPSIS
    <mocker.Mock object at ...>
    >>> mocker.result(7)
    
  3. method3 应该调用 method1method2,而我们刚刚创建的模拟对象期望看到对 method1method2 的调用。到目前为止,一切顺利,那么让这个模拟对象成为 method3 调用的 self 的技巧是什么呢?以下是测试的其余部分:

    >>> mocker.replay()
    >>> testable.method3.im_func(target, 12)
    12
    

刚才发生了什么?

我们去了 testable 类,查找它的 method3 成员,这是一个被称为“未绑定方法对象”的东西。一旦我们有了未绑定方法对象,我们就查看了它的 im_func 属性,它只是一个函数,没有任何与方法相关联的华丽装饰。一旦我们手头有了普通函数,调用它就很容易了,我们可以将我们的模拟对象作为它的第一个参数传递。

注意

Python 3.0 版本通过移除未绑定方法对象,直接在类中存储函数对象来简化了这一点。这意味着如果您使用的是 Python 3.0 或更高版本,您可以直接调用 testable.method3(target, 12)

摘要

在本章中,我们关于模拟和 Python Mocker 学到了很多。我们关注了 Mocker 提供的各种功能,以帮助您保持单元之间的分离。

具体来说,我们涵盖了什么是模拟对象,它们的作用,如何使用 Python Mocker 使模拟更容易,许多自定义 Mocker 行为以适应您需求的方法,以及如何用一个模拟对象替换方法的 self 参数。

到目前为止,我们已经开始看到 doctest——尽管它简单易用——开始变得难以控制的情况。在下一章中,我们将探讨 Python 的另一个内置单元测试框架:unittest

第五章。当 Doctest 不够用时:unittest 来拯救

随着测试变得更加详细(或复杂),或者它们需要更多的设置代码来为它们做准备时,doctest 开始变得有点烦人。正是这种简单性使其成为编写可测试规范和其他简单测试的最佳方式,但它开始干扰编写复杂事物的测试。

在本章中,我们将:

  • 学习如何在 unittest 框架中编写和执行测试

  • 学习如何使用 unittest 表达熟悉的测试概念

  • 讨论使 unittest 适合更复杂测试场景的具体特性

  • 了解几个与 unittest 很好地集成的 Mocker 特性

那我们就开始吧!

基本 unittest

在我们开始讨论新的概念和特性之前,让我们看看如何使用 unittest 来表达我们已经学过的想法。这样,我们就能有一个坚实的基础来巩固我们的新理解。

行动时间 – 使用 unittest 测试 PID

我们将重新审视第三章中的 PID 类(或者至少是 PID 类的测试)。我们将重写测试,以便它们在 unittest 框架内运行。

在继续之前,花点时间回顾一下第三章中pid.txt文件的最终版本。我们将使用 unittest 框架实现相同的测试。

  1. 在与pid.py相同的目录下创建一个名为test_pid.py的新文件。请注意,这是一个.py文件:unittest 测试是纯 Python 源代码,而不是包含源代码的纯文本。这意味着测试在文档方面可能不太有用,但会带来其他好处。

  2. 将以下代码插入你新创建的test_pid.py文件中(请注意,有几行足够长,可能会在书的页面上换行):

    from unittest import TestCase, main
    from mocker import Mocker
    
    import pid
    
    class test_pid_constructor(TestCase):
        def test_without_when(self):
            mocker = Mocker()
            mock_time = mocker.replace('time.time')
            mock_time()
            mocker.result(1.0)
    
            mocker.replay()
    
            controller = pid.PID(P=0.5, I=0.5, D=0.5,
                                 setpoint=0, initial=12)
    
            mocker.restore()
            mocker.verify()
    
            self.assertEqual(controller.gains, (0.5, 0.5, 0.5))
            self.assertAlmostEqual(controller.setpoint[0], 0.0)
            self.assertEqual(len(controller.setpoint), 1)
            self.assertAlmostEqual(controller.previous_time, 1.0)
            self.assertAlmostEqual(controller.previous_error, -12.0)
            self.assertAlmostEqual(controller.integrated_error, 0)
    
        def test_with_when(self):
            controller = pid.PID(P=0.5, I=0.5, D=0.5,
                                 setpoint=1, initial=12,
                                 when=43)
    
            self.assertEqual(controller.gains, (0.5, 0.5, 0.5))
            self.assertAlmostEqual(controller.setpoint[0], 1.0)
            self.assertEqual(len(controller.setpoint), 1)
            self.assertAlmostEqual(controller.previous_time, 43.0)
            self.assertAlmostEqual(controller.previous_error, -11.0)
            self.assertAlmostEqual(controller.integrated_error, 0)
    
    class test_calculate_response(TestCase):
        def test_without_when(self):
            mocker = Mocker()
            mock_time = mocker.replace('time.time')
            mock_time()
            mocker.result(1.0)
            mock_time()
            mocker.result(2.0)
            mock_time()
            mocker.result(3.0)
            mock_time()
            mocker.result(4.0)
            mock_time()
            mocker.result(5.0)
    
            mocker.replay()
    
            controller = pid.PID(P=0.5, I=0.5, D=0.5,
                                 setpoint=0, initial=12)
    
            self.assertEqual(controller.calculate_response(6), -3)
            self.assertEqual(controller.calculate_response(3), -4.5)
            self.assertEqual(controller.calculate_response(-1.5), -0.75)
            self.assertEqual(controller.calculate_response(-2.25), -1.125)
    
            mocker.restore()
            mocker.verify()
        def test_with_when(self):
            controller = pid.PID(P=0.5, I=0.5, D=0.5,
                                 setpoint=0, initial=12,
                                 when=1)
    
            self.assertEqual(controller.calculate_response(6, 2), -3)
            self.assertEqual(controller.calculate_response(3, 3), -4.5)
            self.assertEqual(controller.calculate_response(-1.5, 4), -0.75)
            self.assertEqual(controller.calculate_response(-2.25, 5), -1.125)
    
    if __name__ == '__main__':
        main()
    
  3. 通过输入以下命令来运行测试:

    $ python test_pid.py
    
    

    行动时间 – 使用 unittest 测试 PID

刚才发生了什么?

让我们逐行查看代码部分,看看每个部分的作用。之后,我们将讨论将这些部分组合在一起时的意义。

from unittest import TestCase, main
from mocker import Mocker

import pid

class test_pid_constructor(TestCase):
    def test_without_when(self):
        mocker = Mocker()
        mock_time = mocker.replace('time.time')
        mock_time()
        mocker.result(1.0)

        mocker.replay()

        controller = pid.PID(P=0.5, I=0.5, D=0.5,
                             setpoint=0, initial=12)

        mocker.restore()
        mocker.verify()

        self.assertEqual(controller.gains, (0.5, 0.5, 0.5))
        self.assertAlmostEqual(controller.setpoint[0], 0.0)
        self.assertEqual(len(controller.setpoint), 1)
        self.assertAlmostEqual(controller.previous_time, 1.0)
        self.assertAlmostEqual(controller.previous_error, -12.0)
        self.assertAlmostEqual(controller.integrated_error, 0)

在一点设置代码之后,我们有一个测试,当没有提供when参数时,PID 控制器能正确工作。Mocker 被用来替换time.time,使其总是返回一个可预测的值,然后我们使用几个断言来确认控制器的属性已被初始化为预期的值。

    def test_with_when(self):
        controller = pid.PID(P=0.5, I=0.5, D=0.5,
                             setpoint=1, initial=12,
                             when=43)

        self.assertEqual(controller.gains, (0.5, 0.5, 0.5))
        self.assertAlmostEqual(controller.setpoint[0], 1.0)
        self.assertEqual(len(controller.setpoint), 1)
        self.assertAlmostEqual(controller.previous_time, 43.0)
        self.assertAlmostEqual(controller.previous_error, -11.0)
        self.assertAlmostEqual(controller.integrated_error, 0)

这个测试确认当提供when参数时,PID 构造函数能正确工作。与之前的测试不同,不需要使用 Mocker,因为测试的结果不应该依赖于任何东西,除了参数值——当前时间是不相关的。

class test_calculate_response(TestCase):
    def test_without_when(self):
        mocker = Mocker()
        mock_time = mocker.replace('time.time')
        mock_time()
        mocker.result(1.0)
        mock_time()
        mocker.result(2.0)
        mock_time()
        mocker.result(3.0)
        mock_time()
        mocker.result(4.0)
        mock_time()
        mocker.result(5.0)

        mocker.replay()

        controller = pid.PID(P=0.5, I=0.5, D=0.5,
                             setpoint=0, initial=12)

        self.assertEqual(controller.calculate_response(6), -3)
        self.assertEqual(controller.calculate_response(3), -4.5)
        self.assertEqual(controller.calculate_response(-1.5), -0.75)
        sel+f.assertEqual(controller.calculate_response(-2.25), -1.125)

        mocker.restore()
        mocker.verify()

这个类中的测试描述了calculate_response方法预期的行为。这个第一个测试检查当可选的when参数未提供时的行为,并模拟time.time以使该行为可预测。

    def test_with_when(self):
        controller = pid.PID(P=0.5, I=0.5, D=0.5,
                             setpoint=0, initial=12,
                             when=1)

        self.assertEqual(controller.calculate_response(6, 2), -3)
        self.assertEqual(controller.calculate_response(3, 3), -4.5)
        self.assertEqual(controller.calculate_response(-1.5, 4), -0.75)
        self.assertEqual(controller.calculate_response(-2.25, 5), -1.125)

在这个测试中,提供了when参数,因此不需要模拟time.time。我们只需检查结果是否符合预期。

我们实际执行的测试与 doctest 中编写的测试相同。到目前为止,我们看到的是表达它们的不同方式。

首先要注意的是,测试文件被划分为继承自unittest.TestCase的类,每个类包含一个或多个测试方法。每个测试方法的名称都以单词test开头,这是 unittest 识别它们为测试的方式。

每个测试方法体现了一个单一单元的单一测试。这为我们提供了一个方便的方式来组织测试,将相关的测试组合到同一个类中,以便更容易找到。

将每个测试放入自己的方法意味着每个测试都在一个独立的命名空间中执行,这使得相对于 doctest 风格的测试,unittest 风格的测试之间相互干扰的可能性降低。这也意味着 unittest 知道你的测试文件中有多少个单元测试,而不是简单地知道有多少个表达式(你可能已经注意到 doctest 将每行>>>视为一个单独的测试)。最后,将每个测试放入自己的方法意味着每个测试都有一个名称,这可以是一个非常有价值的特性。

unittest 中的测试不直接关心TestCase的 assert 方法调用之外的任何内容。这意味着当我们使用 Mocker 时,除非我们想要使用它们,否则不需要担心从演示表达式返回的模拟对象。这也意味着我们需要记住为想要检查的测试的每个方面编写一个断言。我们将在稍后介绍TestCase的各种断言方法。

如果不能执行测试,测试就没有多大用处。目前,我们将通过在 Python 解释器将我们的测试文件作为程序执行时调用unittest.main来实现这一点。这是运行 unittest 代码最简单的方法,但当你有很多分散在多个文件中的测试时,这会很麻烦。我们将在下一章学习关于解决这个问题的工具。

注意

if __name__ == '__main__':可能看起来有些奇怪,但它的意义相当直接。当 Python 加载任何模块时,它会将模块的名称存储在模块内的一个名为__name__的变量中(除非模块是传递给解释器的命令行上的模块)。该模块总是将字符串'__main__'绑定到其__name__变量。因此,if __name__ == '__main__':意味着——如果这个模块直接从命令行执行。

断言

断言是我们用来告诉 unittest 测试的重要结果的机制。通过使用适当的断言,我们可以告诉 unittest 每个测试期望得到什么。

assertTrue

当我们调用 self.assertTrue(expression) 时,我们正在告诉 unittest,表达式必须为真,测试才能成功。

这是一个非常灵活的断言,因为你可以通过编写适当的布尔表达式来检查几乎所有东西。它也是你最后应该考虑使用的断言之一,因为它没有告诉 unittest 你正在进行的比较类型,这意味着如果测试失败,unittest 不能清楚地告诉你出了什么问题。

为了说明这个问题,考虑以下包含两个保证失败的测试的测试代码:

from unittest import TestCase, main

class two_failing_tests(TestCase):
    def test_assertTrue(self):
        self.assertTrue(1 == 1 + 1)

    def test_assertEqual(self):
        self.assertEqual(1, 1 + 1)

if __name__ == '__main__':
    main()

可能看起来这两个测试是可以互换的,因为它们都测试相同的东西。当然,它们都会失败(或者在极不可能的情况下,一个等于两个,它们都会通过),那么为什么偏爱其中一个而不是另一个呢?

看看当我们运行测试时会发生什么(同时注意测试的执行顺序并不与它们编写的顺序相同;测试彼此完全独立,所以这是可以接受的,对吧?):

assertTrue

你看到区别了吗?assertTrue 测试能够正确地确定测试应该失败,但它不知道足够的信息来报告任何关于失败原因的有用信息。另一方面,assertEqual 测试首先知道它正在检查两个表达式是否相等,其次它知道如何呈现结果,以便它们最有用:通过评估它比较的每个表达式,并在结果之间放置一个 != 符号。它告诉我们哪些期望失败了,以及相关的表达式评估结果是什么。

assertFalse

assertTrue 方法失败时,assertFalse 方法将成功,反之亦然。它在产生有用输出方面的限制与 assertTrue 相同,并且在能够测试几乎所有条件方面的灵活性也相同。

assertEqual

如同在 assertTrue 讨论中提到的,assertEqual 断言检查其两个参数实际上是否相等,如果它们不相等,则报告失败,并附带参数的实际值。

assertNotEqual

assertEqual 断言成功时,assertNotEqual 断言将失败,反之亦然。当它报告失败时,其输出表明两个表达式的值相等,并提供了这些值。

assertAlmostEqual

正如我们之前看到的,比较浮点数可能会有麻烦。特别是,检查两个浮点数是否相等是有问题的,因为那些你可能期望相等的东西——在数学上相等的东西——最终可能仍然在最低有效位上有所不同。只有当每个位都相同时,浮点数才相等。

为了解决这个问题,unittest 提供了 assertAlmostEqual,它检查两个浮点值几乎相同;它们之间的一小部分差异是可以容忍的。

让我们看看这个问题在实际中的应用。如果你对 7 开平方,然后再平方,结果应该是 7。这里有一对测试来检查这个事实:

from unittest import TestCase, main

class floating_point_problems(TestCase):
    def test_assertEqual(self):
        self.assertEqual((7.0 ** 0.5) ** 2.0, 7.0)
    def test_assertAlmostEqual(self):
        self.assertAlmostEqual((7.0 ** 0.5) ** 2.0, 7.0)

if __name__ == '__main__':

    main()

test_assertEqual 方法检查 assertAlmostEqual,这在现实中是正确的。然而,在计算机可用的更专业的数字系统中,对 7 开平方后再平方并不完全回到 7,因此这个测试会失败。关于这一点,稍后会有更多说明。

测试 test_assertAlmostEqual 方法检查 assertAlmostEqual,即使是计算机也会同意这是正确的,因此这个测试应该通过。

运行这些测试会产生以下结果,尽管你得到的数字可能因测试运行的计算机的具体细节而异:

assertAlmostEqual

不幸的是,浮点数并不精确,因为实数线上的大多数数字不能用有限的非重复数字序列来表示,更不用说只有 64 位了。因此,从评估数学表达式得到的并不完全是 7。虽然对于政府工作或几乎所有其他类型的工作来说已经足够接近,但我们不希望我们的测试对那个微小的差异斤斤计较。正因为如此,当我们比较浮点数是否相等时,我们应该使用 assertAlmostEqualassertNotAlmostEqual

注意

这种问题通常不会影响到其他比较运算符。例如,检查一个浮点数是否小于另一个浮点数,由于微小的误差,产生错误结果的可能性非常低。只有在相等的情况下,这个问题才会困扰我们。

assertNotAlmostEqual

assertNotAlmostEqual 断言在 assertAlmostEqual 断言成功时失败,反之亦然。当它报告失败时,其输出表明两个表达式的值几乎相等,并提供了这些值。

assertRaises

总是,我们需要确保我们的单元能够正确地报告错误。当它们接收到良好输入时做正确的事情只是工作的一半;它们还需要在接收到不良输入时做合理的事情。

assertRaises 方法检查一个可调用对象(可调用对象是一个函数、一个方法或一个类。可调用对象也可以是任何具有 __call__ 方法的任意类型的对象)在传递指定的参数集时是否会引发指定的异常。

这个断言只适用于可调用对象,这意味着你没有方法来检查其他类型的表达式是否会引发预期的异常。如果这不符合你的测试需求,你可以使用下面描述的 fail 方法来构建自己的测试。

要使用 assertRaises,首先传递预期的异常,然后传递可调用对象,最后传递在调用可调用对象时应传递的参数。

这里有一个使用assertRaises的示例测试。这个测试应该会失败,因为可调用函数不会抛出预期的异常。当你也传递base = 16时,'8ca2'int的完全可接受的输入。注意,assertRaises将接受任意数量的位置参数或关键字参数,并在调用时将它们传递给可调用函数。

from unittest import TestCase, main

class silly_int_test(TestCase):
    def test_int_from_string(self):
        self.assertRaises(ValueError, int, '8ca2', base = 16)

if __name__ == '__main__':
    main()

当我们运行这个测试时,它会失败(正如我们所预料的),因为int没有抛出我们告诉assertRaises期望的异常。

assertRaises

如果抛出了一个异常,但不是你告诉 unittest 期望的异常,unittest 会将其视为一个错误。错误与失败不同。失败意味着你的某个测试检测到了它所测试的单元中的问题。错误意味着测试本身存在问题。

fail

当所有其他方法都失败时,你可以退回到fail。当你的测试代码调用fail时,测试就会失败。

这有什么好处?当没有 assert 方法能满足你的需求时,你可以将你的检查编写成这样,如果测试没有通过,就会调用fail。这允许你使用 Python 的全部表达能力来描述你的期望检查。

让我们来看一个示例。这次,我们将对一个小于操作进行测试,这不是 assert 方法直接支持的操作之一。使用fail,我们仍然可以轻松实现这个测试。

from unittest import TestCase, main

class test_with_fail(TestCase):
    def test_less_than(self):
        if not (2.3 < 5.6):
            self.fail('2.3 is not less than 5.6, but it should be')

if __name__ == '__main__':
    main()

这里有几个需要注意的地方:首先,注意if语句中的not。由于我们希望在测试不应该通过时运行fail,但我们习惯于描述测试应该成功的情况,因此编写测试的一个好方法是先写出成功条件,然后用not取反。这样我们就可以继续以我们习惯的方式使用 fail。其次,需要注意的是,当你调用fail时,可以传递一个消息,这个消息将在 unittest 的失败测试报告中打印出来。如果你选择一个恰当的消息,它可能会非常有帮助。

由于这个测试应该通过,报告中不会包含任何有趣的内容,因此没有屏幕截图显示运行这个测试的预期结果。你可以尝试改变测试并运行它,看看会发生什么。

快速问答 - 基本的 unittest 知识

  1. 这个 doctest 的 unittest 等价物是什么?

    >>> try:
    ...     int('123')
    ... except ValueError:
    ...     pass
    ... else:
    ...     print 'Expected exception was not raised'
    
  2. 你如何检查两个浮点数是否相等?

  3. 你会在什么情况下选择使用assertTrue?又或者fail

尝试一下英雄 - 转换为 unittest

回顾一下我们在前几章中编写的某些测试,并将它们从 doctests 转换为 unittests。鉴于你已经对 unittest 有所了解,你应该能够将任何测试进行转换。

当你这样做的时候,考虑一下 unittest 和 doctest 对于每个翻译的测试的相对优点。这两个系统有不同的优势,因此对于每种情况都将是更合适的选择。doctest 何时是更好的选择,何时是 unittest?

测试夹具

Unittest 具有一个重要且非常有用的功能,这是 doctest 所缺乏的。你可以告诉 unittest 如何为你的单元测试创建一个标准化的环境,以及如何在测试完成后清理该环境。能够创建和销毁一个标准化的测试环境是测试夹具的功能。虽然测试夹具并没有使之前不可能进行的任何测试成为可能,但它们确实可以使测试更短、更少重复。

行动时间 - 测试数据库支持的单元

许多程序需要访问数据库以进行操作,这意味着这些程序由许多也访问数据库的单元组成。关键是数据库的目的是存储信息并使其在其他任意位置可访问(换句话说,数据库的存在是为了打破单元的隔离)。(同样的问题也适用于其他信息存储:例如,永久存储中的文件。)

我们如何处理这个问题?毕竟,仅仅不测试与数据库交互的单元并不是解决方案。我们需要创建一个环境,其中数据库连接按常规工作,但所做的任何更改都不会持续。我们可以以几种不同的方式做到这一点,但无论细节如何,我们都需要在每次使用它的测试之前设置特殊的数据库连接,并在每次此类测试之后销毁任何更改。

Unittest 通过提供TestCase类的setUptearDown方法来帮助我们做到这一点。这些方法供我们重写,默认版本不执行任何操作。

下面是一些使用数据库的代码(假设它存在于一个名为employees.py的文件中),我们将为它编写测试:

注意

此代码使用随 Python 一起提供的sqlite3数据库。由于sqlite3接口与 Python 的 DB-API 2.0 兼容,因此你使用的任何数据库后端都将具有与这里看到类似的接口。

class employees:
    def __init__(self, connection):
        self.connection = connection

    def add_employee(self, first, last, date_of_employment):
        cursor = self.connection.cursor()
        cursor.execute('''insert into employees
                            (first, last, date_of_employment)
                          values
                            (:first, :last, :date_of_employment)''',
                       locals())
        self.connection.commit()

        return cursor.lastrowid

    def find_employees_by_name(self, first, last):
        cursor = self.connection.cursor()
        cursor.execute('''select * from employees
                          where
                            first like :first
                          and
                            last like :last''',
                       locals())

        for row in cursor:
            yield row

    def find_employees_by_date(self, date):
        cursor = self.connection.cursor()
        cursor.execute('''select * from employees
                          where date_of_employment = :date''',
                       locals())

        for row in cursor:
            yield row
  1. 我们将开始编写测试,通过导入所需的模块并介绍我们的TestCase类。

    from unittest import TestCase, main
    from sqlite3 import connect, PARSE_DECLTYPES
    from datetime import date
    from employees import employees
    
    class test_employees(TestCase):
    
  2. 我们需要一个setUp方法来创建我们的测试所依赖的环境。在这种情况下,这意味着创建一个新的数据库连接到仅内存的数据库,并使用所需的表和行填充该数据库。

        def setUp(self):
            connection = connect(':memory:',
                                 detect_types=PARSE_DECLTYPES)
            cursor = connection.cursor()
    
            cursor.execute('''create table employees
                                (first text,
                                 last text,
                                 date_of_employment date)''')
    
            cursor.execute('''insert into employees
                                (first, last, date_of_employment)
                              values
                                ("Test1", "Employee", :date)''',
                           {'date': date(year = 2003,
                                         month = 7,
                                         day = 12)})
    
            cursor.execute('''insert into employees
                                (first, last, date_of_employment)
                              values
                                ("Test2", "Employee", :date)''',
                           {'date': date(year = 2001,
                                         month = 3,
                                         day = 18)})
    
            self.connection = connection
    
  3. 我们需要一个tearDown方法来撤销setUp方法所做的任何操作,以便每个测试都可以在一个未受干扰的环境中运行。由于数据库仅在内存中,我们只需关闭连接,它就会消失。tearDown在其他场景中可能变得更为复杂。

        def tearDown(self):
            self.connection.close()
    
  4. 最后,我们需要测试本身以及执行测试的代码。

        def test_add_employee(self):
            to_test = employees(self.connection)
            to_test.add_employee('Test1', 'Employee', date.today())
    
            cursor = self.connection.cursor()
            cursor.execute('''select * from employees
                              order by date_of_employment''')
    
            self.assertEqual(tuple(cursor),
                             (('Test2', 'Employee', date(year=2001,
                                                         month=3,
                                                         day=18)),
                              ('Test1', 'Employee', date(year=2003,
                                                         month=7,
                                                         day=12)),
                              ('Test1', 'Employee', date.today())))
    
        def test_find_employees_by_name(self):
            to_test = employees(self.connection)
    
            found = tuple(to_test.find_employees_by_name('Test1', 'Employee'))
            expected = (('Test1', 'Employee', date(year=2003,
                                                   month=7,
                                                   day=12)),)
    
            self.assertEqual(found, expected)
    
        def test_find_employee_by_date(self):
            to_test = employees(self.connection)
    
            target = date(year=2001, month=3, day=18)
            found = tuple(to_test.find_employees_by_date(target))
    
            expected = (('Test2', 'Employee', target),)
    
            self.assertEqual(found, expected)
    
    if __name__ == '__main__':
        main()
    
    

刚才发生了什么?

我们为TestCase使用了setUp方法以及相应的tearDown方法。它们之间确保了测试执行的 环境(这是setUp的工作)以及每个测试的环境在测试运行后被清理,这样测试就不会相互干扰(这是tearDown的工作)。Unittest 确保在每次测试方法之前运行一次setUp,在每次测试方法之后运行一次tearDown

因为测试用例——根据setUptearDown的定义——被包裹在TestCase类中的每个测试周围,所以包含太多测试的TestCase类的setUptearDown可能会变得非常复杂,并且浪费大量时间处理对某些测试不必要的细节。你可以通过将需要特定环境方面的测试组合在一起,为它们创建自己的TestCase类来避免这个问题。为每个TestCase提供一个适当的setUptearDown,只处理包含的测试所必需的环境方面。你可以有任意多的TestCase类,所以在决定将哪些测试组合在一起时,没有必要在这方面节省。

注意我们使用的tearDown方法有多简单。这通常是一个好兆头:当需要撤销的tearDown方法中的更改简单到可以描述时,这通常意味着你可以确信能够完美地完成它。由于tearDown的任何不完美都可能使测试留下可能改变其他测试行为的散乱数据,因此正确执行它非常重要。在这种情况下,我们所有的更改都局限于数据库中,因此删除数据库就能解决问题。

突击测验——测试用例

  1. 测试用例的目的是什么?

  2. 测试用例是如何创建的?

  3. 测试用例能否在没有setUp方法的情况下有tearDown方法?又或者setUp方法在没有tearDown方法的情况下呢?

来试试英雄般的挑战——文件路径抽象

下面是一个描述文件路径抽象的类定义。你的挑战是编写单元测试(使用 unittest),检查该类的每个方法,确保它们的行为符合预期。你需要使用测试用例来创建和销毁文件系统中的沙盒区域,以便你的测试可以运行。

由于 doctest 不支持测试用例,使用该框架编写这些测试会很烦人。你必须在每次测试之前复制创建环境的代码,并在每次测试之后复制清理代码。通过使用unittest,我们可以避免这种重复。

这门课程有几个地方是错误的,或者至少没有像应该的那样正确。看看你是否能在你的测试中捕捉到它们。

from os.path import isfile, isdir, exists, join
from os import makedirs, rmdir, unlink

class path:
    r"""

    Instances of this class represent a file path, and facilitate
    several operations on files and directories.

    Its most surprising feature is that it overloads the division
    operator, so that the result of placing a / operator between two
    paths (or between a path and a string) results in a longer path,
    representing the two operands joined by the system's path
    separator character.

    """

    def __init__(self, target):
        self.target = target

    def exists(self):
        return exists(self.target)

    def isfile(self):
        return isfile(self.target)

    def isdir(self):
        return isdir(self.target)

    def mkdir(self, mode = 493):
        makedirs(self.target, mode)

    def rmdir(self):
        if self.isdir():
            rmdir(self.target)
        else:
            raise ValueError('Path does not represent a directory')

    def delete(self):
        if self.exists():
            unlink(self.target)
        else:
            raise ValueError('Path does not represent a file')

    def open(self, mode = "r"):
        return open(self.target, mode)

    def __div__(self, other):
        if isinstance(other, path):
            return path(join(self.target, other.target))
        return path(join(self.target, other))

    def __repr__(self):
        return '<path %s>' % self.target

与 Python Mocker 集成

你已经足够熟悉 Mocker,可以看到在文本开头创建模拟上下文以及在结尾调用其verifyrestore方法中的重复性。Mocker 通过在 mocker 模块中提供一个名为MockerTestCase的类来简化这一点。MockerTestCase的行为就像一个普通的 unittest TestCase一样,除了对于每个测试,它会自动创建一个模拟上下文,然后在测试之后验证和恢复它。模拟上下文存储在self.mocker中。

以下示例通过使用它来编写一个涉及time.time模拟的测试来演示MockerTestCase。在测试执行之前,一个模拟上下文被存储在self.mocker中。在测试运行之后,上下文会自动验证和恢复。

from unittest import main
from mocker import MockerTestCase
from time import time

class test_mocker_integration(MockerTestCase):
    def test_mocking_context(self):
        mocker = self.mocker
        time_mock = mocker.replace('time.time')
        time_mock()
        mocker.result(1.0)

        mocker.replay()

        self.assertAlmostEqual(time(), 1.0)

if __name__ == '__main__':
    main()

上面的测试是一个简单的测试,它检查当前时间是否为1.0,如果不是因为我们没有模拟time.time,它就不会是。我们不是创建一个新的 Mocker 实例,而是已经有一个名为self.mocker的实例可用,所以我们使用它。我们还能够省略对verifyrestore的调用,因为MockerTestCase会为我们处理这些。

摘要

本章包含了大量关于如何使用 unittest 框架编写测试的信息。

具体来说,我们涵盖了如何使用 unittest 来表达你从 doctest 中已经熟悉的概念,unittest 和 doctest 之间的差异和相似之处,如何使用测试固定装置将你的测试嵌入到一个受控和临时的环境中,以及如何使用 Python Mocker 的MockerTestCase来简化 unittest 和 Mocker 的集成。

到目前为止,我们一直是通过直接指示 Python 运行它们来单独或以小批量运行测试。现在我们已经学习了 unittest,我们准备讨论管理和执行大量测试的话题,这是下一章的主题。

第六章:运行您的测试:跟随 Nose

到目前为止,我们已经讨论了很多关于如何编写测试的内容,但我们还没有详细说明如何运行它们。我们不得不明确告诉 Python 要运行哪些测试,我们要么担心我们使用的 Python 版本(在 doctest 的情况下),要么在每个模块中放置一个if __name__ == '__main__'。显然,在运行测试方面还有改进的空间。

在本章中,我们将:

  • 了解一个名为 Nose 的 Python 工具,它可以自动找到并执行测试

  • 学习如何让 Nose 找到并执行 doctest 测试

  • 学习如何让 Nose 找到并执行 unittest 测试

  • 学习如何使用 Nose 的内部测试框架

那么让我们开始吧!

什么是 Nose?

Nose 是一个工具,可以一步找到并运行您所有的测试。它可以在多个文件中找到测试,组织它们,运行它们,并在最后向您展示一份漂亮的报告。您不需要在文件中添加任何特殊代码来使测试可运行,也不必担心您正在运行的 Python 版本,除非您的测试使用了语言中最近添加的新特性。Nose 理解 doctest 和 unittest 测试;它甚至为两者添加了一些功能。

安装 Nose

在撰写本文时,Nose 的主页是code.google.com/p/python-nose/,您可以在code.google.com/p/python-nose/downloads/list找到可用的下载。请下载最新版本,并将其解压缩到临时目录中。如果您使用的是 Windows,您需要像 7-Zip(7-zip.org/)这样的程序来解压缩文件;Linux 和 Mac 用户不需要任何特殊软件。

解压缩 Nose 后,我们需要安装它。在安装 Mocker 时需要考虑的所有事情在这里同样适用:如果您安装了 Python,您只需切换到 Nose 目录并输入:

$ python setup.py install

如果您没有安装 Python,但正在使用 2.6 或更高版本,您可以输入以下命令:

$ python setup.py install --user 

注意

如果您选择--user安装,您可能需要将一个目录添加到操作系统的搜索路径中。如果您在安装后无法运行nosetests程序,您就会知道需要这样做。在 Linux 或 Mac 上,您需要添加的目录是~/.local/bin,而在 Windows 上则是%APPDATA%\Python\Scripts。另外,在 Windows 上,您可能需要在%APPDATA%\Python\Scripts目录中创建一个名为nosetests.bat的文件,其中包含以下行:@python %APPDATA%\Python\Scripts\nosetests.

有时,一个名为easy_install的工具可以简化 Python 模块和包的安装过程。如果您想尝试一下,请从pypi.python.org/pypi/setuptools下载并安装 setuptools,然后运行命令 easy_install nose。一旦执行了这个命令,您就应该可以使用 Nose 了。

安装后,您应该能够在命令行中键入其名称来运行nosetests。如果您在空目录中运行它,您应该看到类似以下输出:

安装 Nose

组织测试

好的,我们已经安装了 Nose,那么它有什么好处呢?Nose 会遍历目录结构,找到测试文件,整理出它们包含的测试,运行测试,并将结果反馈给您。这节省了您每次运行测试时(您应该经常这样做)需要做的很多工作。

Nose 根据文件名识别测试文件。任何文件名包含testTest,无论是位于开头还是跟在任意字符_.(通常称为下划线、点或破折号)之后,都被识别为包含 unittest TestCases(或 Nose 自己的测试函数,我们稍后会讨论)的文件,应该执行。任何名称符合相同模式的目录都被识别为可能包含测试的目录,因此应该搜索测试文件。Nose 还可以找到并执行 doctest 测试,无论是嵌入在 docstrings 中还是单独编写在测试文件中。默认情况下,它不会寻找 doctest 测试,除非我们告诉它。我们很快就会看到如何更改默认设置。

由于 Nose 非常愿意寻找我们的测试,我们在组织测试方面有很大的自由度。通常,将所有测试分离到它们自己的目录中,或者对于更大的项目,分离到整个目录树中,都是一个很好的主意。大型项目可能最终会有数千个测试,因此为了便于导航而组织它们是一个很大的好处。如果 doctests 被用作文档以及测试,那么将它们存储在另一个单独的目录中,并使用一个传达它们是文档的名称,可能是一个好主意。对于中等规模的项目,建议的结构可能如下所示:

组织测试

该结构仅作为一个建议(这是为了您的利益,而不是为了 Nose)。如果不同的结构能让您更容易操作,请随意使用。

行动时间 - 组织前几章的测试

我们将从前几章的测试中提取出来,将它们全部组织成一个目录树。然后我们将使用 Nose 来运行它们。

  1. 创建一个目录来存放我们的代码和测试;您可以为它选择任何名称,但在这里我将称它为project

  2. pid.pyoperations.pytestable.py放入project目录中。当我们运行nosetestsproject目录下时,存储在project中的模块(和包)将可供所有测试访问,无论测试存储在目录树中的哪个位置。

  3. 创建一个名为test_chapter2的子目录,并将第二章的test.txttest.py文件放入其中。

  4. 创建一个名为test_chapter3的子目录,并将第三章的最终pid.txt文件放入其中。

  5. 创建一个名为test_chapter4的子目录,并将第四章示例(如果你有)和执行动作部分中的代码放入其中。

  6. 创建一个名为test_chapter5的子目录,并将第五章示例(如果你有)和执行动作部分中的代码放入其中。因为第五章使用了 unittest 测试,我们还需要重命名每个文件,以便 Nose 能识别它们为测试文件。文件的好名字可以是:

    test_equal_and_almost_equal.py,  test_fail.py,  test_mocker_test_case.py,  test_pid.py,  test_raises.py,  test_setup_teardown.py,  test_true_and_false.py.
    
    
  7. 现在你已经将所有测试整理并组织好了,让我们来运行它们。为此,切换到project目录并输入以下命令:

    $ nosetests --with-doctest --doctest-extension=txt -v
    
    

    注意

    如果你不想使用-v选项,也可以。它只是告诉 Nose 提供更详细的操作报告。

  8. 所有测试都应该运行。我们预计会看到一些失败,因为一些来自前几章的测试是为了说明目的而故意设计的。不过,有一个失败需要我们考虑:执行动作时间 - 组织前几章的测试

  9. 错误报告的第一部分可以安全忽略:它只是意味着整个 doctest 文件被 Nose 当作一个失败的测试处理。有用的信息在报告的第二部分。它告诉我们,我们期望得到一个 1.0 的先前时间,但我们得到的是一个非常大的数字(当你自己运行测试时,这个数字会不同,并且更大,因为它实际上代表了几十年前的某个时间点以来的秒数)。发生了什么?我们没有为那个测试替换time.time吗?让我们看看pid.txt的相关部分:

    >>> import time
    >>> real_time = time.time
    >>> time.time = (float(x) for x in xrange(1, 1000)).next
    >>> import pid
    >>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,
    ...                      initial = 12)
    >>> controller.gains
    (0.5, 0.5, 0.5)
    >>> controller.setpoint
    [0.0]
    >>> controller.previous_time
    1.0
    
  10. 我们模拟了time.time,但我们使用的是临时方法,而不是通过 Mocker 的replace方法。这意味着在测试文件执行之前导入的模块,如果使用了from time import time,将会导入真实的time函数,而不知道我们的模拟。那么,pid.py是在pid.txt导入之前被其他东西导入的吗?实际上,是的:Nose 在扫描要执行的测试时将其导入。如果我们使用 Nose,我们不能指望我们的导入语句是导入任何给定模块的第一个。不过,我们可以通过使用 Mocker 轻松解决这个问题(注意,我们在这里只查看文件中的第一个测试。还有一个测试也需要以同样的方式修复):

    >>> from mocker import Mocker
    >>> mocker = Mocker()
    >>> mock_time = mocker.replace('time.time')
    >>> t = mock_time()
    >>> mocker.result(1.0)
    >>> mocker.replay()
    >>> import pid
    >>> controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,
    ...                      initial = 12)
    >>> controller.gains
    (0.5, 0.5, 0.5)
    >>> controller.setpoint
    [0.0]
    >>> controller.previous_time
    1.0
    >>> controller.previous_error
    -12.0
    >>> controller.integrated_error
    0.0
    >>> mocker.restore()
    >>> mocker.verify()
    
  11. 现在我们再次使用 nosetests 运行测试时,唯一的失败是预期的。这是 Nose 打印的概述,因为我们传递了-v命令行选项:执行动作时间 - 组织前几章的测试

发生了什么?

我们使用一条命令运行了所有这些测试。相当不错,对吧?我们现在正达到测试变得广泛有用的地步。

多亏了 Nose,我们不需要在每个 unittest 文件末尾的愚蠢的if __name__ == '__main__'块,也不需要记住任何神秘的命令来执行 doctest 文件。我们可以将我们的测试存储在单独且井然有序的目录结构中,并通过一个简单、快速、单一的命令运行它们。我们还可以通过传递包含我们想要运行的测试的文件名、模块名或目录作为命令行参数,轻松地运行测试的子集。

我们还看到了隐藏的假设如何破坏测试,就像它们可以破坏被测试的代码一样。到目前为止,我们一直假设当我们的测试导入一个模块时,这是模块第一次被导入。一些测试依赖于这个假设来用模拟对象替换库对象。现在我们正在处理运行许多聚合在一起的测试,没有保证执行顺序的情况,这个假设并不可靠。更不用说,我们遇到麻烦的模块实际上必须被导入以在运行任何测试之前搜索它。那会是一个问题,但我们已经有一个工具可以替换库对象,无论导入顺序如何。只需快速切换受影响的测试使用 Mocker,我们就可以继续前进。

查找 doctests

我们在上一节中使用的nosetests命令相当容易理解,但每次输入时仍然有点长。而不是:

$ nosetests --with-doctest --doctest-extension=txt -v

我们真的很希望能够直接输入:

$ nosetests -v

或者甚至:

$ nosetests

为了执行我们的测试,并且仍然能够找到并执行所有的 doctests。

幸运的是,告诉 Nose 我们想要它为那些命令行开关的值使用不同的默认值是一件简单的事情。为此,只需在你的主目录中创建一个名为nose.cfg.noserc(任一名称都行)的配置文件,并在其中放置以下内容:

[nosetests]
with-doctest=1
doctest-extension=txt

从现在开始,每次你运行nosetests,它都会假设那些选项,除非你告诉它否则。你不再需要在命令行上输入它们。你可以为 Nose 可以接受的所有命令行选项使用同样的技巧。

注意

如果你是一个 Windows 用户,你可能不确定在这个上下文中短语“主目录”指的是什么。就 Python 而言,你的主目录由你的环境变量定义。如果HOME被定义,那就是你的主目录。否则,如果USERPROFILE被定义(通常是这样,指向C:\Documents and Settings\USERNAME),那么那被认为是你的主目录。否则,由HOMEDRIVEHOMEPATH(通常是C:\)描述的目录是你的主目录。

自定义 Nose 的搜索

我们之前说过,Nose 会在以testTest开头的目录和模块中查找测试,或者包含一个'_''.''-'后跟testTest的字符。这是默认设置,但并不是全部故事。

如果你了解正则表达式,你可以自定义 Nose 用来查找测试的模板。你可以通过传递--include=REGEX命令行选项或在你的nose.cfg.noserc文件中放入include=REGEX来实现这一点。

例如,如果你这样做:

nosetests --include="(?:^[Dd]oc)"

除了上述描述的查找名称外,Nose 还会查找以docDoc开头的名称。这意味着你可以将包含你的 doctest 文件的目录命名为docsDocumentationdoctests等,Nose 仍然会找到它并运行测试。如果你经常使用此选项,你几乎肯定希望将其添加到你的配置文件中,如前一个标题下所述。

注意

正则表达式的完整语法和使用是一个主题本身,并且已经成为了许多书籍的主题。然而,你可以在 Python 文档中找到你需要的一切,以在docs.python.org/library/re.html中做这类事情。

快速问答——使用 Nose 进行测试

  1. 通过运行nosetests --processes=4,Nose 可以启动四个测试进程,如果你在一个四核系统上运行测试,这将提供很大的性能提升。你将如何让 Nose 始终启动四个测试进程,而无需在命令行中指定?

  2. 如果一些测试存储在一个名为specs的目录中,你将如何告诉 Nose 它应该在该目录中搜索测试?

  3. 以下哪个默认情况下会被 Nose 识别为可能包含测试的:UnitTestsunit_testsTestFilestest_filesdoctests

尝试一下英雄——Nose 探索

为以下规范编写一些doctestunittest测试,并创建一个目录树来包含它们以及它们描述的代码。使用测试驱动的方法编写代码,并使用 Nose 运行测试。

The graph module contains two classes: Node and Arc. An Arc is a connection between two Nodes. Each Node is an intersection of an arbitrary number of Arcs.

Arc objects contain references to the Node objects that the Arc connects, a textual identification label, and a "cost" or "weight", which is a real number.

Node objects contain references to all of the connected Arcs, and a textual identification label.

Node objects have a find_cycle(self, length) method which returns a list of Arcs making up the lowest cost complete path from the Node back to itself, if such a path exists with a length greater than 2 Arcs and less than or equal to the length parameter.

Node and Arc objects have a __repr__(self) method which returns a representation involving the identification labels assigned to the objects.

Nose 和 doctest

不只是支持 doctest,它实际上增强了它。当你使用 Nose 时,你可以为你的 doctest 文件编写测试固定值。

如果你通过命令行传递--doctest-fixtures=_fixture,Nose 将在找到 doctest 文件时寻找一个固定值文件。固定值文件的名字基于 doctest 文件的名字,通过在 doctest 文件名的主要部分后添加 doctest 固定值后缀(换句话说,doctest-fixtures的值)并添加.py到末尾来计算。例如,如果 Nose 找到一个名为pid.txt的 doctest 文件,并且被告知doctest-fixtures=_fixture,它将尝试在一个名为pid_fixture.py的文件中找到测试固定值。

doctest 的测试固定值文件非常简单:它只是一个包含setup()setUp()函数和teardown()tearDown()函数的 Python 模块。设置函数在 doctest 文件之前执行,而清理函数在之后执行。

装置在 doctest 文件的不同命名空间中运行,因此固定装置模块中定义的任何变量在实际测试中都是不可见的。如果你想在装置和测试之间共享变量,你可能需要创建一个简单的模块来保存这些变量,你可以将其导入到装置和测试中。

在 doctest 装置中进行的模拟替换工作得很好。只要你在设置期间不调用restore()(你为什么要做这样愚蠢的事情?),那么当测试使用替换的对象时,它们仍然会保留。

行动时间 – 为 doctest 创建固定装置

我们将在测试装置中提供一个模拟的time.time()并在我们的 doctest 中使用它。

  1. 创建一个名为times.txt的文件,包含以下 doctest 代码:

    >>> from time import time
    
    This isn't a reasonable test for any purpose, but it serves to
    illustrate a test that can't work without a mock object in place.
    
    >>> '%0.1f' % time()
    '1.0'
    >>> '%0.1f' % time()
    '1.1'
    >>> '%0.1f' % time()
    '1.2'
    
  2. 使用 Nose 运行 doctest 文件,以下屏幕会显示:行动时间 – 为 doctest 创建固定装置

  3. 除非你的计算机时钟恰好重置到纪元的开始,否则 doctest 会失败。如果我们想这些测试能够可靠地通过,我们需要一个模拟来替换time.time()。创建一个名为times_fixture.py的文件,并插入以下 Python 代码:

    from mocker import Mocker
    
    mocker = Mocker()
    
    def setup():
        fake_time = mocker.replace('time.time')
    
        fake_time()
        mocker.result(1.0)
        fake_time()
        mocker.result(1.1)
        fake_time()
        mocker.result(1.2)
    
        mocker.replay()
    
    def teardown():
        mocker.restore()
        mocker.verify()
    
  4. 现在我们运行 Nose 并告诉它如何找到 doctest 固定装置,doctest 通过,因为它使用了我们在装置中设置的模拟:行动时间 – 为 doctest 创建固定装置

  5. 如果你经常使用这个功能,将doctest-fixtures=_fixture添加到你的 Nose 配置文件中是有意义的。

Nose 和 unittest

Nose 通过在包和模块级别提供测试装置来增强 unittest。包设置函数在包中任何模块的任何测试之前运行,而清理函数在包中所有模块的所有测试完成后运行。同样,模块设置在给定模块的任何测试执行之前运行,模块清理在模块中所有测试执行之后执行。

行动时间 – 创建模块固定装置

我们将使用模块级别的固定装置来构建一个测试模块。在这个装置中,我们将替换datetime.date.today函数,它通常返回一个表示当前日期的对象。我们希望它返回一个特定的值,这样我们的测试就可以知道期望什么。

  1. 创建一个名为tests的目录。在这个行动时间以及下一个中,我们将使用这个目录。

  2. tests目录中,创建一个名为module_fixture_tests.py的文件,包含以下代码:

    from unittest import TestCase
    from mocker import Mocker
    from datetime import date
    
    mocker = Mocker()
    
    def setup():
        fake_date = mocker.replace(date)
    
        fake_date.today()
        mocker.result(date(year = 2009, month = 6, day = 12))
        mocker.count(1, None)
    
        mocker.replay()
    
    def teardown():
        mocker.restore()
        mocker.verify()
    
    class first_tests(TestCase):
        def test_year(self):
            self.assertEqual(date.today().year, 2009)
    
        def test_month(self):
            self.assertEqual(date.today().month, 6)
    
        def test_day(self):
            self.assertEqual(date.today().day, 12)
    
    class second_tests(TestCase):
        def test_isoformat(self):
            self.assertEqual(date.today().isoformat(), '2009-06-12')
    
  3. 注意,在这个模块中有两个TestCase类。使用纯 unittest,我们不得不在每个类中复制固定装置代码。

  4. 前往包含tests目录的目录,并输入以下命令来运行测试:

    $ nosetests
    
    
  5. Nose 会将测试识别为可能包含测试的目录(因为目录名),找到module_fixtures_tests.py文件,运行setup函数,运行所有测试,然后运行teardown函数。不过,除了一个简单的测试通过报告外,你不会看到太多内容。

刚才发生了什么?

通过使用一个额外的“层”测试固定件,我们将整个测试模块而不是单个测试方法封装起来,从而节省了一些时间和精力。通过这样做,我们避免了在每个测试类中重复固定件代码,但这种节省是有代价的。与正常的测试固定件不同,设置和清理不是在每个测试前后运行。相反,模块中的所有测试都在单个模块级设置/清理对之间发生,这意味着如果测试执行了影响设置函数创建的环境的操作,它不会在下一个测试运行之前被撤销。换句话说,测试的环境隔离性不能保证。

现在,我们将通过包括包级测试固定件来扩展之前的行动时间。与模块级测试固定件一样,这也是 Nose 的一个节省劳动力的特性。

行动时间 – 创建包固定件

现在我们将创建一个封装整个包中所有测试模块的测试固定件。

  1. 在上一节行动时间中创建的tests目录中添加一个名为__init__.py的新文件。(这是两个下划线,单词'init',还有两个下划线)。此文件的存在告诉 Python 该目录是一个包。将以下代码放置在tests目录下的__init__.py文件中:

    from mocker import Mocker
    from datetime import datetime
    
    mocker = Mocker()
    
    def setup():
        fake_datetime = mocker.replace(datetime)
    
        fake_datetime.now()
        mocker.result(datetime(year = 2009, month = 6, day = 12,
                               hour = 10, minute = 15, second = 5))
        mocker.count(1, None)
    
        mocker.replay()
    
    def teardown():
        mocker.restore()
        mocker.verify()
    

    注意

    __init__.py文件通常是空的,但它们是放置适用于整个包的通用代码的完美地方,因此 Nose 就在那里寻找包级固定件。

  2. tests目录下添加一个名为package_fixtures_tests.py的新文件,其内容如下:

    from unittest import TestCase
    from datetime import datetime
    
    class first_tests(TestCase):
        def test_year(self):
            self.assertEqual(datetime.now().year, 2009)
    
        def test_month(self):
            self.assertEqual(datetime.now().month, 6)
    
        def test_day(self):
            self.assertEqual(datetime.now().day, 12)
    
        def test_hour(self):
            self.assertEqual(datetime.now().hour, 10)
    
        def test_minute(self):
            self.assertEqual(datetime.now().minute, 15)
    
        def test_second(self):
            self.assertEqual(datetime.now().second, 5)
    
  3. 将以下代码添加到已存在的module_fixtures_tests.py文件中(我们也可以将其放在自己的文件中。重点是将其放置在与步骤 2 中的测试分开的模块中,以便你能看到包测试固定件已经就位):

    from datetime import datetime
    class third_tests(TestCase):
        def test_isoformat(self):
            self.assertEqual(datetime.now().isoformat(), 
                             '2009-06-12T10:15:05')
    
  4. 继续再次运行测试。(你不会看到太多输出,但这意味着一切正常)转到包含tests的目录,并运行以下命令:

    $ nosetests
    
    

刚才发生了什么?

我们与另一个测试固定件层合作,这次是封装了tests目录中的所有测试模块。从我们刚刚编写的代码中可以看出,包级测试固定件创建的环境在包中每个模块的每个测试中都是可用的。

与模块级测试固定件一样,包级测试固定件可以是一个节省大量劳动的快捷方式。然而,它们并不提供与真实测试级固定件相同的保护,以防止测试之间的通信。

Nose 的自身测试框架

Nose 支持两种新的测试类型:独立的测试函数和非 TestCase 测试类。它通过使用与查找测试模块相同的模式匹配来找到这些测试。当遍历一个名称与模式匹配的模块时,任何名称也匹配该模式的函数或类都被假定是测试。

行动时间 - 使用 Nose 特定的测试

我们将编写一些测试来展示 Nose 对测试函数和非 TestCase 测试类的支持。

  1. 创建一个名为nose_specific_tests.py的文件,并包含以下内容:

    import sys
    from sqlite3 import connect
    
    class grouped_tests:
        def setup(self):
            self.connection = connect(':memory:')
            cursor = self.connection.cursor()
            cursor.execute('create table test (a, b, c)')
            cursor.execute('''insert into test (a, b, c) 
                              values (1, 2, 3)''')
            self.connection.commit()
    
        def teardown(self):
            self.connection.close()
    
        def test_update(self):
            cursor = self.connection.cursor()
            cursor.execute('update test set b = 7 where a = 1')
    
        def test_select(self):
            cursor = self.connection.cursor()
            cursor.execute('select * from test limit 1')
            assert cursor.fetchone() == (1, 2, 3)
    
  2. 现在将以下文本添加到同一文件中,在grouped_tests类之外:

    def platform_setup():
        sys.platform = 'test platform'
    
    def platform_teardown():
        global sys
        sys = reload(sys)
    
    def standalone_test():
        assert sys.platform == 'test platform'
    
    standalone_test.setup = platform_setup
    standalone_test.teardown = platform_teardown
    
  3. 运行测试,尽管像往常一样,你不想看到除测试执行报告之外的任何输出:

    $ nosetests
    
    

刚才发生了什么?

grouped_tests类包含一个测试固定装置(setupteardown方法)和两个测试,但它不是一个 unittest 的TestCase类。Nose 将其识别为测试类,因为其名称遵循 Nose 在检查模块名称以查找测试模块时寻找的相同模式。然后它遍历该类以查找测试固定装置(以及任何测试方法),并适当地运行它们。

由于该类不是TestCase,测试无法访问 unittest 的任何assert方法;Nose 认为这种测试通过,除非它引发异常。Python 有一个assert语句,如果其表达式为假,则会引发异常,这对于这种情况很有帮助。它不如assertEqual那样优雅,但在许多情况下可以完成任务。

我们在standalone_test函数中编写了另一个测试。像grouped_tests一样,standalone_test被 Nose 识别为测试,因为其名称与 Nose 用于搜索测试模块的相同模式匹配。Nose 将standalone_test作为测试运行,如果它引发异常,则报告失败。

我们能够通过将standalone_testsetupteardown属性设置为定义的函数对来将其附加到standalone_test上。像往常一样,设置函数在测试函数之前执行,而清理函数在测试函数之后执行。

摘要

在本章中,我们关于 Nose 测试元框架学到了很多。

具体来说,我们涵盖了:

  • Nose 如何找到包含测试的文件,以及如何将此过程适应到你的组织方案中

  • 如何使用 Nose 运行所有测试,无论它们是 doctest、unittest 还是 Nose 特定的测试

  • 如何通过额外的测试固定装置支持增强其他框架

  • 如何使用 Nose 的测试函数和非 TestCase 测试类

现在我们已经了解了 Nose 和轻松运行所有测试,我们准备着手一个完整的测试驱动型项目——这是下一章的主题。

第七章. 开发测试驱动型项目

在本章中,我们不会讨论 Python 中测试的新技术,也不会花太多时间讨论测试的哲学。相反,我们将逐步通过一个实际开发过程的记录。谦逊且不幸易犯错的作者记录了他的错误——以及测试如何帮助他修复这些错误——在开发个人调度程序的一部分时。

在本章中,我们将:

  • 编写可测试的规范

  • 编写单元测试

  • 编写符合规范和单元测试的代码

  • 使用可测试的规范和单元测试来帮助调试

阅读本章时,你将被提示设计和构建自己的模块,这样你就可以走自己的过程了。

编写规范

如往常一样,过程从书面规范开始。规范是一个 doctest(我们在第二章和第三章中了解过),因此计算机可以使用它来检查实现。尽管规范并不是一组单元测试;为了使文档更易于人类读者阅读,我们暂时牺牲了单元测试的纪律。这是一个常见的权衡,只要你也编写覆盖代码的单元测试来弥补,那就没问题。

项目目标是创建一个能够表示个人时间管理信息的 Python 包。

以下代码放在一个名为docs/outline.txt的文件中:

This project is a personal scheduling system intended to keep track of a single person's schedule and activities. The system will store and display two kinds of schedule information: activities and statuses. Activities and statuses both support a protocol which allows them to be checked for overlap with another object supporting the protocol.

>>> from planner.data import activities, statuses
>>> from datetime import datetime

Activities and statuses are stored in schedules, to which they can be added and removed.

>>> from planner.data import schedules
>>> activity = activities('test activity',
...                       datetime(year=2009, month=6, day=1,
...                                hour=10, minute=15),
...                       datetime(year=2009, month=6, day=1,
...                                hour=12, minute=30))
>>> duplicate_activity = activities('test activity',
...                       datetime(year=2009, month=6, day=1,
...                                hour=10, minute=15),
...                       datetime(year=2009, month=6, day=1,
...                                hour=12, minute=30))
>>> status = statuses('test status',
...                   datetime(year=2009, month=7, day=1,
...                            hour=10, minute=15),
...                   datetime(year=2009, month=7, day=1,
...                            hour=12, minute=30))
>>> schedule = schedules()
>>> schedule.add(activity)
>>> schedule.add(status)
>>> status in schedule
True
>>> activity in schedule
True
>>> duplicate_activity in schedule
True
>>> schedule.remove(activity)
>>> schedule.remove(status)
>>> status in schedule
False
>>> activity in schedule
False

Activities represent tasks that the person must actively engage in, and they are therefore mutually exclusive: no person can have two activities that overlap the same period of time.

>>> activity1 = activities('test activity 1',
...                        datetime(year=2009, month=6, day=1,
...                                 hour=9, minute=5),
...                        datetime(year=2009, month=6, day=1,
...                                 hour=12, minute=30))
>>> activity2 = activities('test activity 2',
...                        datetime(year=2009, month=6, day=1,
...                                 hour=10, minute=15),
...                        datetime(year=2009, month=6, day=1,
...                                 hour=13, minute=30))
>>> schedule = schedules()
>>> schedule.add(activity1)
>>> schedule.add(activity2) # doctest:+ELLIPSIS
Traceback (most recent call last):
schedule_error: "test activity 2" overlaps with "test activity 1"

Statuses represent tasks that a person engages in passively, and so
can overlap with each other and with activities.

>>> activity1 = activities('test activity 1',
...                        datetime(year=2009, month=6, day=1,
...                                 hour=9, minute=5),
...                        datetime(year=2009, month=6, day=1,
...                                 hour=12, minute=30))
>>> status1 = statuses('test status 1',
...                    datetime(year=2009, month=6, day=1,
...                             hour=10, minute=15),
...                    datetime(year=2009, month=6, day=1,
...                             hour=13, minute=30))
>>> status2 = statuses('test status 2',
...                    datetime(year=2009, month=6, day=1,
...                             hour=8, minute=45),
...                    datetime(year=2009, month=6, day=1,
...                             hour=15, minute=30))
>>> schedule = schedules()
>>> schedule.add(activity1)
>>> schedule.add(status1)
>>> schedule.add(status2)
>>> activity1 in schedule
True
>>> status1 in schedule
True
>>> status2 in schedule
True

Schedules can be saved to a sqlite database, and they can be reloaded
from that stored state.

>>> from planner.persistence import file
>>> storage = file(':memory:')
>>> schedule.store(storage)
>>> newsched = schedules.load(storage)
>>> schedule == newsched
True

这个 doctest 将作为我项目的可测试规范,这意味着它将成为所有其他测试(以及我的程序代码)的基础。让我们更详细地看看每个部分:

This project is a personal scheduling system intended to keep track of a single person's schedule and activities. The system will store and display two kinds of schedule information: activities and statuses. Activities and statuses both support a protocol which allows them to be checked for overlap with another object supporting the protocol.

>>> from planner.data import activities, statuses
>>> from datetime import datetime

上述代码包含一些简介性英文文本和几个导入语句,这些导入语句引入了我们需要用于这些测试的代码。通过这样做,它们也告诉我们planner包的一些结构。具体来说,它们告诉我们它包含一个名为data的模块,该模块定义了活动和状态。

>>> from planner.data import schedules
>>> activity = activities('test activity',
...                       datetime(year=2009, month=6, day=1,
...                                hour=10, minute=15),
...                       datetime(year=2009, month=6, day=1,
...                                hour=12, minute=30))
>>> duplicate_activity = activities('test activity',
...                       datetime(year=2009, month=6, day=1,
...                                hour=10, minute=15),
...                       datetime(year=2009, month=6, day=1,
...                                hour=12, minute=30))
>>> status = statuses('test status',
...                   datetime(year=2009, month=7, day=1,
...                            hour=10, minute=15),
...                   datetime(year=2009, month=7, day=1,
...                            hour=12, minute=30))
>>> schedule = schedules()
>>> schedule.add(activity)
>>> schedule.add(status)
>>> status in schedule
True
>>> activity in schedule
True
>>> duplicate_activity in schedule
True
>>> schedule.remove(activity)
>>> schedule.remove(status)
>>> status in schedule
False
>>> activity in schedule
False

上述测试描述了schedulesactivitiesstatuses的一些期望行为。根据这些测试,schedules必须接受activitiesstatuses作为其addremove方法的参数。一旦它们被添加,in运算符必须返回True,直到对象被移除。此外,具有相同参数的两个activities必须被schedules视为同一个对象。

>>> activity1 = activities('test activity 1',
...                        datetime(year=2009, month=6, day=1,
...                                 hour=9, minute=5),
...                        datetime(year=2009, month=6, day=1,
...                                 hour=12, minute=30))
>>> activity2 = activities('test activity 2',
...                        datetime(year=2009, month=6, day=1,
...                                 hour=10, minute=15),
...                        datetime(year=2009, month=6, day=1,
...                                 hour=13, minute=30))
>>> schedule = schedules()
>>> schedule.add(activity1)
>>> schedule.add(activity2) # doctest:+ELLIPSIS
Traceback (most recent call last):
schedule_error: "test activity 2" overlaps with "test activity 1"

上述测试代码描述了当重叠活动被添加到日程表中时应该发生什么。具体来说,应该引发一个schedule_error

>>> activity1 = activities('test activity 1',
...                        datetime(year=2009, month=6, day=1,
...                                 hour=9, minute=5),
...                        datetime(year=2009, month=6, day=1,
...                                 hour=12, minute=30))
>>> status1 = statuses('test status 1',
...                    datetime(year=2009, month=6, day=1,
...                             hour=10, minute=15),
...                    datetime(year=2009, month=6, day=1,
...                             hour=13, minute=30))
>>> status2 = statuses('test status 2',
...                    datetime(year=2009, month=6, day=1,
...                             hour=8, minute=45),
...                    datetime(year=2009, month=6, day=1,
...                             hour=15, minute=30))
>>> schedule = schedules()
>>> schedule.add(activity1)
>>> schedule.add(status1)
>>> schedule.add(status2)
>>> activity1 in schedule
True
>>> status1 in schedule
True
>>> status2 in schedule
True

上述测试代码描述了当重叠状态被添加到日程表中时应该发生什么:日程表应该接受它们。此外,如果状态和活动重叠,它们仍然都可以被添加。

>>> from planner.persistence import file
>>> storage = file(':memory:')
>>> schedule.store(storage)
>>> newsched = schedules.load(storage)
>>> schedule == newsched
True

上述代码描述了调度存储应该如何工作。它还告诉我们,planner 包需要包含一个 persistence 模块,而这个模块反过来应该包含 file

行动时间——你打算做什么?

是时候你自己想出一个项目了,一个你可以独立工作的项目;我们逐步通过开发过程:

  1. 想想一个与本章所述项目大致相同复杂性的项目。它应该是一个单独的模块或一个包中的几个模块。

  2. 想象一下项目已经完成,你需要编写一个描述你所做的工作,以及一些演示代码的说明。然后继续编写你的描述和演示代码,以 doctest 文件的形式。

  3. 当你编写 doctest 文件时,要注意那些你的原始想法需要稍作改变以使演示更容易编写或工作得更好的地方。当你找到这样的案例时,请注意它们!在这个阶段,最好稍作改变,以便在整个过程中节省精力。

刚才发生了什么?

我们现在有了适度规模项目的可测试规范,这将帮助我们编写单元测试和代码,并让我们对整个项目的完整性有一个整体的感觉。

此外,将代码写入 doctest 的过程给了我们测试驱动我们想法的机会。我们可能通过具体使用它们来改进我们的项目,尽管项目实现仍然仅仅是想象中的。

再次强调,在编写将要测试的代码之前编写这些测试非常重要。通过先编写测试,我们给自己提供了一个可以用来判断我们的代码是否符合我们意图的基准。如果我们先编写代码,然后编写测试,最终我们只是将代码实际执行的行为——而不是我们希望它执行的行为——神圣化到测试中。

编写初始单元测试

由于规范不包含单元测试,在模块编码开始之前,仍然需要单元测试。planner.data 类是第一个要实现的目标,因此它们是第一个接受测试的。

活动和状态被定义为非常相似,因此它们的测试模块也是相似的。尽管它们并不完全相同,也不需要具有任何特定的继承关系,所以测试仍然是独立的。

以下测试位于 tests/test_activities.py

from unittest import TestCase
from mocker import MockerTestCase
from planner.data import activities, task_error
from datetime import datetime

class constructor_tests(TestCase):
    def test_valid(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2008, month=4, day=27))

        self.assertEqual(activity.name, 'activity name')
        self.assertEqual(activity.begins,
                         datetime(year = 2007, month = 9, day = 11))
        self.assertEqual(activity.ends,
                         datetime(year = 2008, month = 4, day = 27))

    def test_backwards_times(self):
        self.assertRaises(task_error,
                          activities,
                          'activity name',
                          datetime(year=2008, month=4, day=27),
                          datetime(year=2007, month=9, day=11))

    def test_too_short(self):
        self.assertRaises(task_error,
                          activities,
                          'activity name',
                          datetime(year = 2008, month = 4, day = 27,
                                   hour = 7, minute = 15),
                          datetime(year = 2008, month = 4, day = 27,
                                   hour = 7, minute = 15))

class utility_tests(TestCase):
    def test_repr(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2008, month=4, day=27))

        expected = "<activity name 2007-09-11T00:00:00 2008-04-27T00:00:00>"

        self.assertEqual(repr(activity), expected)

class exclusivity_tests(TestCase):
    def test_excludes(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2007, month=10, day=6))

        # Any activity should exclude any other activity
        self.assertTrue(activity.excludes(activity))

        # Anything not known to be excluded should be included
        self.assertFalse(activity.excludes(None))

class overlap_tests(MockerTestCase):
    def setUp(self):
        pseudo = self.mocker.mock()

        pseudo.begins
        self.mocker.result(datetime(year=2007, month=10, day=7))
        self.mocker.count(0, None)

        pseudo.ends
        self.mocker.result(datetime(year=2008, month=2, day=5))
        self.mocker.count(0, None)

        self.other = pseudo

        self.mocker.replay()

    def test_overlap_before(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2007, month=10, day=6))

        self.assertFalse(activity.overlaps(self.other))

    def test_overlap_begin(self):
        activity = activities('activity name',
                              datetime(year=2007, month=8, day=11),
                              datetime(year=2007, month=11, day=27))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_end(self):
        activity = activities('activity name',
                              datetime(year=2008, month=1, day=11),
                              datetime(year=2008, month=4, day=16))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_inner(self):
        activity = activities('activity name',
                              datetime(year=2007, month=10, day=11),
                              datetime(year=2008, month=1, day=27))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_outer(self):
        activity = activities('activity name',
                              datetime(year=2007, month=8, day=12),
                              datetime(year=2008, month=3, day=15))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_after(self):
        activity = activities('activity name',
                              datetime(year=2008, month=2, day=6),
                              datetime(year=2008, month=4, day=27))

        self.assertFalse(activity.overlaps(self.other))

让我们来看看上述单元测试代码的每个部分:

    def test_valid(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2008, month=4, day=27))

        self.assertEqual(activity.name, 'activity name')
        self.assertEqual(activity.begins,
                         datetime(year = 2007, month = 9, day = 11))
        self.assertEqual(activity.ends,
                         datetime(year = 2008, month = 4, day = 27))

test_valid 方法检查当所有参数都正确时构造函数是否工作正常。这是一个重要的测试,因为它定义了正常情况下正确行为应该是什么。但我们还需要更多的测试来定义异常情况下的正确行为。

    def test_backwards_times(self):
        self.assertRaises(task_error,
                          activities,
                          'activity name',
                          datetime(year=2008, month=4, day=27),
                          datetime(year=2007, month=9, day=11))

在这里,我们将确保你不能创建一个在开始之前就结束的活动。这没有任何意义,并且很容易在实现过程中导致假设错误。

    def test_too_short(self):
        self.assertRaises(task_error,
                          activities,
                          'activity name',
                          datetime(year = 2008, month = 4, day = 27,
                                   hour = 7, minute = 15),
                          datetime(year = 2008, month = 4, day = 27,
                                   hour = 7, minute = 15))

我们也不希望活动非常短。在现实世界中,一个不占用时间的活动是没有意义的,所以我们在这里有一个测试来确保不允许这种情况发生。

class utility_tests(TestCase):
    def test_repr(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2008, month=4, day=27))

        expected = "<activity name 2007-09-11T00:00:00 2008-04-27T00:00:00>"

        self.assertEqual(repr(activity), expected)

虽然 repr(activity) 在任何生产代码路径中可能不太可能被使用,但在开发和调试期间非常方便。这个测试定义了活动文本表示应该如何看起来,以确保它包含所需的信息。

class exclusivity_tests(TestCase):
    def test_excludes(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2007, month=10, day=6))

        # Any activity should exclude any other activity
        self.assertTrue(activity.excludes(activity))

        # Anything not known to be excluded should be included
        self.assertFalse(activity.excludes(None))

由于活动在重叠时应该相互排斥,所以我们在这里进行检查。显然,活动与其自身重叠,因此 excludes 方法应该返回 True。另一方面,活动不应该仅仅假设它排除了所有其他事物,因此在对未知对象(如 None)调用 excludes 时应该返回 False

class overlap_tests(MockerTestCase):
    def setUp(self):
        pseudo = self.mocker.mock()

        pseudo.begins
        self.mocker.result(datetime(year=2007, month=10, day=7))
        self.mocker.count(0, None)

        pseudo.ends
        self.mocker.result(datetime(year=2008, month=2, day=5))
        self.mocker.count(0, None)

        self.other = pseudo

        self.mocker.replay()

在这里,我们创建了一个测试固定装置,它创建了一个模拟对象,该对象假装是一个活动或状态。我们将在接下来的几个测试中使用这个模拟对象(称为 self.other)。

    def test_overlap_before(self):
        activity = activities('activity name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2007, month=10, day=6))

        self.assertFalse(activity.overlaps(self.other))

    def test_overlap_begin(self):
        activity = activities('activity name',
                              datetime(year=2007, month=8, day=11),
                              datetime(year=2007, month=11, day=27))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_end(self):
        activity = activities('activity name',
                              datetime(year=2008, month=1, day=11),
                              datetime(year=2008, month=4, day=16))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_inner(self):
        activity = activities('activity name',
                              datetime(year=2007, month=10, day=11),
                              datetime(year=2008, month=1, day=27))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_outer(self):
        activity = activities('activity name',
                              datetime(year=2007, month=8, day=12),
                              datetime(year=2008, month=3, day=15))

        self.assertTrue(activity.overlaps(self.other))

    def test_overlap_after(self):
        activity = activities('activity name',
                              datetime(year=2008, month=2, day=6),
                              datetime(year=2008, month=4, day=27))

        self.assertFalse(activity.overlaps(self.other))

这些测试描述了活动重叠检查在以下情况下的行为:

  • 在模拟对象之后

  • 与模拟对象重叠的末尾

  • 与模拟对象的开始重叠

  • 在模拟对象之后开始并在其之前结束

  • 在模拟对象之前开始并在其之后结束

以下测试位于 tests/test_statuses.py 文件中。其中许多测试与 activities 的测试类似。我们将重点关注它们之间的差异:

from unittest import TestCase
from mocker import MockerTestCase
from planner.data import statuses, task_error
from datetime import datetime

class constructor_tests(TestCase):
    def test_valid(self):
        status = statuses('status name',
                          datetime(year=2007, month=9, day=11),
                          datetime(year=2008, month=4, day=27))

        self.assertEqual(status.name, 'status name')
        self.assertEqual(status.begins,
                         datetime(year=2007, month=9, day=11))
        self.assertEqual(status.ends,
                         datetime(year=2008, month=4, day=27))

    def test_backwards_times(self):
        self.assertRaises(task_error,
                          statuses,
                          'status name',
                          datetime(year=2008, month=4, day=27),
                          datetime(year=2007, month=9, day=11))

    def test_too_short(self):
        self.assertRaises(task_error,
                          statuses,
                          'status name',
                          datetime(year=2008, month=4, day=27,
                                   hour=7, minute=15),
                          datetime(year=2008, month=4, day=27,
                                   hour=7, minute=15))

class utility_tests(TestCase):
    def test_repr(self):
        status = statuses('status name',
                              datetime(year=2007, month=9, day=11),
                              datetime(year=2008, month=4, day=27))

        expected = "<status name 2007-09-11T00:00:00 2008-04-27T00:00:00>"

        self.assertEqual(repr(status), expected)

class exclusivity_tests(TestCase):
    def test_excludes(self):
        status = statuses('status name',
                          datetime(year=2007, month=9, day=11),
                          datetime(year=2007, month=10, day=6))

        # A status shouldn't exclude anything
        self.assertFalse(status.excludes(status))
        self.assertFalse(status.excludes(None))

class overlap_tests(MockerTestCase):
    def setUp(self):
        pseudo = self.mocker.mock()

        pseudo.begins
        self.mocker.result(datetime(year=2007, month=10, day=7))
        self.mocker.count(1, None)

        pseudo.ends
        self.mocker.result(datetime(year=2008, month=2, day=5))
        self.mocker.count(1, None)

        self.other = pseudo

        self.mocker.replay()

    def test_overlap_before(self):
        status = statuses('status name',
                          datetime(year=2007, month=9, day=11),
                          datetime(year=2007, month=10, day=6))

        self.assertFalse(status.overlaps(self.other))

    def test_overlap_begin(self):
        status = statuses('status name',
                          datetime(year=2007, month=8, day=11),
                          datetime(year=2007, month=11, day=27))

        self.assertTrue(status.overlaps(self.other))

    def test_overlap_end(self):
        status = statuses('status name',
                          datetime(year=2008, month=1, day=11),
                          datetime(year=2008, month=4, day=16))

        self.assertTrue(status.overlaps(self.other))

    def test_overlap_inner(self):
        status = statuses('status name',
                          datetime(year=2007, month=10, day=11),
                          datetime(year=2008, month=1, day=27))

        self.assertTrue(status.overlaps(self.other))

    def test_overlap_outer(self):
        status = statuses('status name',
                          datetime(year=2007, month=8, day=12),
                          datetime(year=2008, month=3, day=15))

        self.assertTrue(status.overlaps(self.other))

    def test_overlap_after(self):
        status = statuses('status name',
                          datetime(year=2008, month=2, day=6),
                          datetime(year=2008, month=4, day=27))

        self.assertFalse(status.overlaps(self.other))

与前一个测试文件相比,这个测试文件有一个显著的不同点,即 test_excludes 方法。

class exclusivity_tests(TestCase):
    def test_excludes(self):
        status = statuses('status name',
                          datetime(year=2007, month=9, day=11),
                          datetime(year=2007, month=10, day=6))

        # A status shouldn't exclude anything
        self.assertFalse(status.excludes(status))
        self.assertFalse(status.excludes(None))

与活动不同,状态永远不应该强迫自己与其他事物相互排斥,因此这个测试使用 assertFalse 而不是 assertTrue 进行第一个断言。

以下测试位于 tests/test_schedules.py 文件中。我们在 setUp 方法中定义了几个模拟对象,它们的行为就像 activitiesstatuses 一样。这些模拟对象模拟活动或状态,因此通过使用它们而不是真实的活动或状态,我们可以检查 schedules 类是否正确处理了重叠或非重叠的事件,以及它们是否相互排除,所有这些都不需要使用正在测试的单元外的代码。

from unittest import TestCase
from mocker import MockerTestCase, ANY
from planner.data import schedules, schedule_error
from datetime import datetime

class add_tests(MockerTestCase):
    def setUp(self):

        overlap_exclude = self.mocker.mock()
        overlap_exclude.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        overlap_exclude.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)

        overlap_include = self.mocker.mock()
        overlap_include.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        overlap_include.excludes(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)

        distinct_exclude = self.mocker.mock()
        distinct_exclude.overlaps(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        distinct_exclude.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)

        distinct_include = self.mocker.mock()
        distinct_include.overlaps(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        distinct_include.excludes(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)

        self.overlap_exclude = overlap_exclude
        self.overlap_include = overlap_include
        self.distinct_exclude = distinct_exclude
        self.distinct_include = distinct_include

        self.mocker.replay()

    def test_add_overlap_exclude(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        self.assertRaises(schedule_error,
                          schedule.add,
                          self.overlap_exclude)

    def test_add_overlap_include(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.overlap_include)

    def test_add_distinct_exclude(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.distinct_exclude)
    def test_add_distinct_include(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.distinct_include)

    def test_add_over_overlap_exclude(self):
        schedule = schedules()
        schedule.add(self.overlap_exclude)
        self.assertRaises(schedule_error,
                          schedule.add,
                          self.overlap_include)

    def test_add_over_distinct_exclude(self):
        schedule = schedules()
        schedule.add(self.distinct_exclude)
        self.assertRaises(schedule_error,
                          schedule.add,
                          self.overlap_include)

    def test_add_over_overlap_include(self):
        schedule = schedules()
        schedule.add(self.overlap_include)
        schedule.add(self.overlap_include)

    def test_add_over_distinct_include(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.overlap_include)

class in_tests(MockerTestCase):
    def setUp(self):
        fake = self.mocker.mock()
        fake.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        fake.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)

        self.fake = fake

        self.mocker.replay()

    def test_in_before_add(self):
        schedule = schedules()
        self.assertFalse(self.fake in schedule)

    def test_in_after_add(self):
        schedule = schedules()
        schedule.add(self.fake)
        self.assertTrue(self.fake in schedule)

让我们逐节回顾那些测试。

    def setUp(self):

        overlap_exclude = self.mocker.mock()
        overlap_exclude.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        overlap_exclude.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)

        overlap_include = self.mocker.mock()
        overlap_include.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        overlap_include.excludes(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)

        distinct_exclude = self.mocker.mock()
        distinct_exclude.overlaps(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        distinct_exclude.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)

        distinct_include = self.mocker.mock()
        distinct_include.overlaps(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        distinct_include.excludes(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)

        self.overlap_exclude = overlap_exclude
        self.overlap_include = overlap_include
        self.distinct_exclude = distinct_exclude
        self.distinct_include = distinct_include

        self.mocker.replay()

我们在这里创建了四个模拟对象:overlap_excludeoverlap_includedistinct_excludedistinct_include。每个对象代表其 overlaps 方法和 excludes 方法的行为的不同组合。在这四个模拟对象之间,我们有重叠或不重叠、排除或不排除的每一种组合。在接下来的测试中,我们将添加这些模拟对象的多种组合到日程表中,并确保它做正确的事情。

    def test_add_overlap_exclude(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        self.assertRaises(schedule_error,
                          schedule.add,
                          self.overlap_exclude)

    def test_add_overlap_include(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.overlap_include)

    def test_add_distinct_exclude(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.distinct_exclude)

    def test_add_distinct_include(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.distinct_include)

这四个测试涵盖了将非重叠对象添加到调度中的情况。所有这些都应该接受非重叠对象,除了第一个。在那个测试中,我们之前添加了一个声称确实重叠的对象,并且进一步排除了它所重叠的任何东西。这个测试表明,如果被添加的对象或已经在调度中的对象认为存在重叠,那么调度必须将其视为重叠。

    def test_add_over_overlap_exclude(self):
        schedule = schedules()
        schedule.add(self.overlap_exclude)
        self.assertRaises(schedule_error,
                          schedule.add,
                          self.overlap_include)

在这个测试中,我们将确保如果一个已经在调度中的对象与新的对象重叠并声称具有排他性,那么添加新的对象将失败。

    def test_add_over_distinct_exclude(self):
        schedule = schedules()
        schedule.add(self.distinct_exclude)
        self.assertRaises(schedule_error,
                          schedule.add,
                          self.overlap_include)

在这个测试中,我们将确保即使已经在调度中的对象认为它没有与新的对象重叠,它也会排除新的对象,因为新的对象认为存在重叠。

    def test_add_over_overlap_include(self):
        schedule = schedules()
        schedule.add(self.overlap_include)
        schedule.add(self.overlap_include)

    def test_add_over_distinct_include(self):
        schedule = schedules()
        schedule.add(self.distinct_include)
        schedule.add(self.overlap_include)

这些测试确保包容性对象不会以某种方式干扰将它们添加到调度中。

class in_tests(MockerTestCase):
    def setUp(self):
        fake = self.mocker.mock()
        fake.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        fake.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)

        self.fake = fake

        self.mocker.replay()

    def test_in_before_add(self):
        schedule = schedules()
        self.assertFalse(self.fake in schedule)

    def test_in_after_add(self):
        schedule = schedules()
        schedule.add(self.fake)
        self.assertTrue(self.fake in schedule)

这两个测试描述了与in操作符相关的调度行为。具体来说,当问题中的对象实际上在调度中时,in应该返回True

是时候采取行动了——用单元测试确定规范

即使是可测试的规范(使用 doctest 编写),也仍然存在许多可以通过良好的单元测试消除的歧义。再加上规范没有在不同测试之间保持分离,你就知道你的项目需要一些单元测试了。

  • 找到你的项目中描述在(或由)规范中(或暗示)的某个元素

  • 编写一个单元测试,描述当给定正确输入时该元素的行为。

  • 编写一个单元测试,描述当给定错误输入时该元素的行为。

  • 编写描述元素在正确和错误输入边界之间行为的单元测试。

  • 如果你可以找到程序中另一个未测试的部分,请回到步骤 1。

刚才发生了什么?

描述程序不需要很多项目符号,但这是一个重要的过程。这是你真正将一个定义不明确的想法转化为你将要做的精确描述的地方。

最终结果可能相当长,这不应该让人感到惊讶。毕竟,在这个阶段,你的目标是完全定义你项目的行为;即使你不关心该行为是如何实现的细节,这也是很多信息。

编码planner.data

是时候编写一些代码了,使用规范文档和单元测试作为指南。具体来说,是时候编写planner.data模块了,该模块包含statusesactivitiesschedules

我创建了一个名为planner的目录,并在该目录中创建了一个名为__init__.py的文件。不需要在__init__.py中放置任何内容,但该文件本身需要存在,以便告诉 Pythonplanner目录是一个包。

以下代码放在planner/data.py中:

from datetime import timedelta

class task_error(Exception):
    pass

class schedule_error(Exception):
    pass

class _tasks:
    def __init__(self, name, begins, ends):
        if ends < begins:
            raise task_error('The begin time must precede the end time')
        if ends - begins < timedelta(minutes = 5):
            raise task_error('The minimum duration is 5 minutes')

        self.name = name
        self.begins = begins
        self.ends = ends

    def excludes(self, other):
        raise NotImplemented('Abstract method. Use a child class.')

    def overlaps(self, other):
        if other.begins < self.begins:
            return other.ends > self.begins
        elif other.ends > self.ends:
            return other.begins < self.ends
        else:
            return True

    def __repr__(self):
        return ''.join(['<', self.name,
                        ' ', self.begins.isoformat(),
                        ' ', self.ends.isoformat(),
                        '>'])

class activities(_tasks):
    def excludes(self, other):
        return isinstance(other, activities)

class statuses(_tasks):
    def excludes(self, other):
        return False

class schedules:
    def __init__(self, name='schedule'):
        self.tasks = []
        self.name = name
    def add(self, task):
        for contained in self.tasks:
            if task.overlaps(contained):
                if task.exclude(contained) or contained.exclude(task):
                    raise schedule_error(task, containeed)

        self.tasks.append(task)

    def remove(self, task):
        try:
            self.tasks.remove(task)
        except ValueError:
            pass

    def __contains__(self, task):
        return task in self.tasks

让我们逐节讨论:

class _tasks:
    def __init__(self, name, begins, ends):
        if ends < begins:
            raise task_error('The begin time must precede the end time')
        if ends - begins < timedelta(minutes = 5):
            raise task_error('The minimum duration is 5 minutes')

        self.name = name
        self.begins = begins
        self.ends = ends

    def excludes(self, other):
        raise NotImplemented('Abstract method. Use a child class.')

    def overlaps(self, other):
        if other.begins < self.begins:
            return other.ends > self.begins
        elif other.ends > self.ends:
            return other.begins < self.ends
        else:
            return True

    def __repr__(self):
        return ''.join(['<', self.name,
                        ' ', self.begins.isoformat(),
                        ' ', self.ends.isoformat(),
                        '>'])

这里的_tasks类包含了activitiesstatuses类所需的大部分行为。由于它们所做的许多事情都是共同的,因此编写一次代码并重用是有意义的。只有excludes方法在每个子类中都需要不同。

class activities(_tasks):
    def excludes(self, other):
        return isinstance(other, activities)

class statuses(_tasks):
    def excludes(self, other):
        return False

class schedules:
    def __init__(self, name='schedule'):
        self.tasks = []
        self.name = name

    def add(self, task):
        for contained in self.tasks:
            if task.overlaps(contained):
                if task.exclude(contained) or contained.exclude(task):
                    raise schedule_error(task, containeed)

        self.tasks.append(task)

    def remove(self, task):
        try:
            self.tasks.remove(task)
        except ValueError:
            pass

    def __contains__(self, task):
        return task in self.tasks

这里是我们测试实际上需要的类的实现。activitiesstatuses类非常简单,因为它们继承自_tasksschedules类也相当简单。但是,它是正确的吗?我们的测试会告诉我们。

使用测试来确保代码正确

好吧,所以代码看起来相当不错。不幸的是,Nose 告诉我们还有一些问题。实际上,Nose 报告了相当多的问题,但需要首先修复的是下面显示的:

使用测试来确保代码正确

我们之所以关注这些错误,而不是选择其他错误,原因很简单。许多其他错误似乎都是由此产生的。单元测试也报告了exclude的问题,因此我们知道这不是由其他错误引起的——记住,单元测试不会相互影响,这与我们规范中的测试不同。

修复代码

要修复第一个错误,请将以下代码添加到planner/data.py中的_tasks类:

    def __eq__(self, other):
        return self.name == other.name and self.begins == other.begins and self.ends == other.ends

    def __ne__(self, other):
        return not self.__eq__(other)

(注意__eq__中的换行)

你可能已经注意到了,这段代码覆盖了两个_tasks之间的相等比较,如果它们具有相同的名称、开始时间和结束时间,则声明它们相等。这是测试代码隐含假设的相等度量标准。

第二个错误可以通过修复schedules.add中的拼写错误来解决:

    def add(self, task):
        for contained in self.tasks:
            if task.overlaps(contained):
                if task.excludes(contained) or contained.excludes(task):
                    raise schedule_error(task, containeed)

        self.tasks.append(task)

在这种情况下,我们将错误的方法名exclude更改为正确的方法名excludes。(再次提醒,注意换行)

因此,我现在再次运行 Nose,它又中断了:

修复代码

幸运的是,这是一个简单的修复:从contained中移除多余的e

                    raise schedule_error(task, contained)

对于怀疑的读者,我不得不承认,是的,那个拼写错误确实在测试捕捉到之前滑过去了。有时测试会捕捉到无聊的错误,而不是戏剧性的问题,是拼写错误而不是逻辑错误。这并不重要,因为无论如何,测试都在帮助你使代码更加稳固、更加可靠和更好。

因此,当我运行 Nose 时,它会中断:

修复代码

好吧,这个也容易修复。错误只是格式错误。通过替换schedules.add中的raise来修复它:

           raise schedule_error('"%s" overlaps with "%s"' %
                                (task.name, contained.name))

这次当我运行 Nose 时,它告诉我我的单元测试是错误的:

修复代码

具体来说,它告诉我我的activitiesstatuses的 mockups 缺少了name属性。这个问题也可以通过更改tests/test_schedules.py中的add_testssetUp方法简单地解决:

    def setUp(self):

        overlap_exclude = self.mocker.mock()
        overlap_exclude.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        overlap_exclude.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        overlap_exclude.name
 self.mocker.result('overlap_exclude')
 self.mocker.count(0, None)

        overlap_include = self.mocker.mock()
        overlap_include.overlaps(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        overlap_include.excludes(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        overlap_include.name
 self.mocker.result('overlap_include')
 self.mocker.count(0, None)

        distinct_exclude = self.mocker.mock()
        distinct_exclude.overlaps(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        distinct_exclude.excludes(ANY)
        self.mocker.result(True)
        self.mocker.count(0, None)
        distinct_exclude.name
                self.mocker.result('distinct_exclude')
 self.mocker.count(0, None)

        distinct_include = self.mocker.mock()
        distinct_include.overlaps(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        distinct_include.excludes(ANY)
        self.mocker.result(False)
        self.mocker.count(0, None)
        distinct_include.name
 self.mocker.result('distinct_include')
 self.mocker.count(0, None)

        self.overlap_exclude = overlap_exclude
        self.overlap_include = overlap_include
        self.distinct_exclude = distinct_exclude
        self.distinct_include = distinct_include

        self.mocker.replay()

修复了那个问题后,Nose 仍然报告错误,但所有这些错误都与持久性有关。这些错误并不令人惊讶,因为没有持久性实现。

行动时间 - 编写和调试代码

基本程序(正如我们之前讨论的),是编写一些代码并运行测试以找到代码中的问题,然后重复。当你遇到一个现有测试未涵盖的错误时,你编写一个新的测试并继续这个过程。

  1. 编写应该满足至少一些测试的代码

  2. 运行你的测试。如果你在我们之前章节讨论它时使用了它,你应该能够通过执行以下操作来运行一切:

     $ nosetests
    
    
  3. 如果你编写的代码中存在错误,请使用测试输出帮助你定位和识别它们。一旦你理解了错误,尝试修复它们,然后回到步骤 2。

  4. 一旦你修复了你编写的代码中的所有错误,如果你的项目还没有完成,选择一些新的测试来集中精力,然后回到步骤 1。

发生了什么?

对这个过程的足够迭代将使你拥有一个完整且经过测试的项目。当然,实际的任务比仅仅说“它会工作”要困难得多,但最终,它会工作。你将产生一个你可以自信的代码库。这也会比没有测试的过程更容易。

你的项目可能已经完成,但在个人计划中还有更多的事情要做。在这一章的这个阶段,我还没有完成编写和调试过程。现在是时候去做这件事了。

编写持久性测试

由于我还没有实际的持久性代码单元测试,我将开始编写一些。在这个过程中,我必须弄清楚持久性实际上是如何工作的。以下代码放在 tests/test_persistence.py 中:

from unittest import TestCase
from mocker import MockerTestCase
from planner.persistence import file

class test_file(TestCase):
    def test_basic(self):
        storage = file(':memory:')
        storage.store_object('tag1', ('some object',))
        self.assertEqual(tuple(storage.load_objects('tag1')),
                         (('some object',),))

    def test_multiple_tags(self):
        storage = file(':memory:')

        storage.store_object('tag1', 'A')
        storage.store_object('tag2', 'B')
        storage.store_object('tag1', 'C')
        storage.store_object('tag1', 'D')
        storage.store_object('tag3', 'E')
        storage.store_object('tag3', 'F')

        self.assertEqual(set(storage.load_objects('tag1')),
                         set(['A', 'C', 'D']))

        self.assertEqual(set(storage.load_objects('tag2')),
                         set(['B']))

        self.assertEqual(set(storage.load_objects('tag3')),
                         set(['E', 'F']))

查看测试代码的每个重要部分,我们看到以下内容:

    def test_basic(self):
        storage = file(':memory:')
        storage.store_object('tag1', ('some object',))
        self.assertEqual(tuple(storage.load_objects('tag1')),
                         (('some object',),))

test_basic 测试创建一个 storage,在名为 tag1 的名称下存储一个单一对象,然后从存储中加载该对象并检查它是否等于原始对象。

    def test_multiple_tags(self):
        storage = file(':memory:')

        storage.store_object('tag1', 'A')
        storage.store_object('tag2', 'B')
        storage.store_object('tag1', 'C')
        storage.store_object('tag1', 'D')
        storage.store_object('tag3', 'E')
        storage.store_object('tag3', 'F')

        self.assertEqual(set(storage.load_objects('tag1')),
                         set(['A', 'C', 'D']))

        self.assertEqual(set(storage.load_objects('tag2')),
                         set(['B']))

        self.assertEqual(set(storage.load_objects('tag3')),
                         set(['E', 'F']))

test_multiple_tags 测试创建一个存储,然后在其中存储多个对象,其中一些具有重复的标签。然后它检查存储是否保留了所有具有给定标签的对象,并在请求时返回所有这些对象。

换句话说,持久性文件是从字符串键到对象值的多元映射。

编写持久性代码

现在至少有基本的单元测试覆盖了持久性机制,是时候编写持久性代码本身了。以下代码放在 planner/persistence.py 中:

import sqlite3
from cPickle import loads, dumps

class file:
    def __init__(self, path):
        self.connection = sqlite3.connect(path)

        try:
            self.connection.execute("""
                create table objects (tag, pickle)
            """)
        except sqlite3.OperationalError:
            pass

    def store_object(self, tag, object):
        self.connection.execute('insert into objects values (?, ?)',
                                (tag, dumps(object)))

    def load_objects(self, tag):
        cursor = self.connection.execute("""
                     select pickle from objects where tag like ?
                 """, (tag,))
        return [loads(row[0]) for row in cursor]

store_object 方法运行一个简短的 SQL 语句将对象存储到数据库字段中。对象序列化由 cPickle 模块的 dumps 函数处理。

load_object方法使用 SQL 查询数据库以获取存储在给定标签下的每个对象的序列化版本,然后使用cPickle.loads将这些序列化转换为实际对象以返回。

现在,我运行 Nose 来找出什么出了问题:

编写持久化代码

我忘记了sqlite返回的是 Unicode 文本数据。Pickle 自然不愿意与 Unicode 字符串一起工作:它期望一个字节字符串,将 Unicode 解释为字节字符串的正确方式是不明确的。这可以通过告诉sqlite将序列化的对象存储为 BLOB(二进制大对象)来解决。修改planner/persistence.pyfilestore_objectload_objects方法:

    def store_object(self, tag, object):
        self.connection.execute('insert into objects values (?, ?)',
                               (tag, sqlite3.Binary(dumps(object))))

    def load_objects(self, tag):
        cursor = self.connection.execute("""
                     select pickle from objects where tag like ?
                 """, (tag,))
        return [loads(str(row[0])) for row in cursor]

现在,Nose 表示schedules类没有storeload方法,这是真的。此外,没有单元测试检查这些方法...唯一的错误来自规范 doctest。是时候在tests/test_schedules.py中编写更多的单元测试了:

from mocker import MockerTestCase, ANY, IN
…
class store_load_tests(MockerTestCase):
    def setUp(self):
        fake_tasks = []
        for i in range(50):
            fake_task = self.mocker.mock()
            fake_task.overlaps(ANY)
            self.mocker.result(False)
            self.mocker.count(0, None)
            fake_task.name
            self.mocker.result('fake %d' % i)
            self.mocker.count(0, None)
            fake_tasks.append(fake_task)

        self.tasks = fake_tasks

    def test_store(self):
        fake_file = self.mocker.mock()

        fake_file.store_object('test_schedule', IN(self.tasks))
        self.mocker.count(len(self.tasks))

        self.mocker.replay()

        schedule = schedules('test_schedule')
        for task in self.tasks:
            schedule.add(task)

        schedule.store(fake_file)

    def test_load(self):
        fake_file = self.mocker.mock()

        fake_file.load_objects('test_schedule')
        self.mocker.result(self.tasks)
        self.mocker.count(1)

        self.mocker.replay()

        schedule = schedules.load(fake_file, 'test_schedule')

        self.assertEqual(set(schedule.tasks),
                         set(self.tasks))

现在我有一些测试可以检查,是时候在planner/data.py中编写schedules类的storeload方法了:

    def store(self, storage):
        for task in self.tasks:
            storage.store_object(self.name, task)

    @staticmethod
    def load(storage, name = 'schedule'):
        value = schedules(name)

        for task in storage.load_objects(name):
            value.add(task)

        return value

注意

@ staticmethod符号表示你可以调用load而不必首先创建 schedules的实例。注意,load方法不接收self参数。

函数装饰器的@语法是在 Python 2.4 中引入的。在 Python 2.2 之前的早期版本中,你可以在方法定义后写load = staticmethod(load),这具有相同的意思。在 Python 2.2 之前,没有staticmethod函数:实现静态“方法”的最简单方法是将其作为同一模块中的独立函数编写。

这组新的测试和代码使我们能够从文件中保存和恢复计划,并消除了大多数剩余的测试失败。planner包几乎完成了!

完成工作

现在,Nose 只报告一个失败的测试,即检查原始schedules实例和从文件中加载的实例是否相等。这里的问题是,又一次,需要重新定义什么是相等。

可以通过在planner/data.py schedules的定义中添加以下内容来修复这个问题:

    def __eq__(self, other):
        if len(self.tasks) != len(other.tasks):
            return False

        left_tasks = list(self.tasks)
        left_tasks.sort(key = (lambda task: task.begins))
        right_tasks = list(other.tasks)
        right_tasks.sort(key = (lambda task: task.begins))
        return tuple(left_tasks) == tuple(right_tasks)

    def __ne__(self, other):
        return not self.__eq__(other)

注意

sortkey参数是在 Python 2.4 中添加的。在那之前版本中,这样的sort看起来会像left_tasks.sort(cmp = (lambda t1, t2: cmp(t1.begins, t2.begins)))

这些方法定义了计划之间的相等性,即它们包含完全相同的任务,并定义不等性为它们不相等的情况(这样定义不等性可能听起来很愚蠢,但实际情况是确实有一些情况你可能希望以不同的方式定义它)。

现在,所有的测试都通过了。不过,在它们通过的方式上,有一些值得注意的地方。具体来说,有几个测试非常慢。一点点的调查揭示,这些慢速测试是处理包含更多任务调度的情况的。这揭示了一个非常重要的事实:schedules实现现在符合测试和规范,但它以天真的方式存储和组织数据,因此扩展性不好。

现在已经有了经过单元测试充分覆盖的工作实现,是时候进行优化了。

快速问答——测试驱动开发

  1. 我在编写可测试规范时没有遵循单元测试的纪律。因为我这样做,我不得不做什么,否则我就不会这样做?选择这条路径是错误的吗?

  2. 是否希望最小化你运行测试的次数?

  3. 如果你开始编写代码之前没有编写任何测试,你失去了哪些机会?

来试试吧,英雄

你完成了自己的项目,我们一起完成了一个项目。现在,是时候尝试完全独立的事情了。我会给你一点帮助来设定目标,但从那里开始,就轮到你了,展示你的才华。

注意

跳表是另一种类似于字典的数据结构。你可以在维基百科上找到很多关于它们的信息,网址是en.wikipedia.org/wiki/Skip_list。使用这些信息(以及你可以找到的任何其他参考资料,如果你愿意的话)和测试驱动的过程,编写你自己的跳表模块。

摘要

在本章中,我们探讨了如何应用本书前面部分介绍过的技能。我们通过回顾你谦逊的作者实际编写包的过程的录音来做到这一点。同时,你也有机会处理自己的项目,做出自己的决定,并设计自己的测试。你已经在一个测试驱动型项目中担任了领导角色,并且应该能够在任何时候再次这样做。

现在我们已经涵盖了 Python 测试的核心内容,我们准备讨论使用 Python 和 Twill 测试基于 Web 的用户界面——这是下一章的主题。

第八章。使用 Twill 测试 Web 应用程序前端

我们还没有讨论过测试用户界面。主要是因为图形用户界面不太适合通过自动化测试工具进行检查(向系统输入输入可能很困难,而且很难解开所有涉及的单元)。然而,Web 应用程序是这一规则的例外,并且它们的重要性一直在增加。

在本章中,我们将:

  • 学习如何使用 Twill 编写与网站交互的脚本

  • 学习如何在测试框架内部运行 Twill 脚本

  • 学习如何将 Twill 操作直接集成到 unittest 测试中

所以,让我们开始吧!

安装 Twill

您可以在 Python 包索引中找到 Twill,网址为 pypi.python.org/pypi/twill/。在撰写本文时,最新版本可以直接从 darcs.idyll.org/~t/projects/twill-0.9.tar.gz 下载。

注意

Windows 用户需要使用一个理解 Tar 和 GZip 格式的存档程序,例如 7-Zip (www.7-zip.org/) 来提取文件。

一旦解压了文件,您可以通过打开命令提示符,切换到 twill-0.9 目录,并运行以下命令来安装它们:

$ python setup.py install

或者,如果您无法写入 Python 的 site-packages 目录,

$ python setup.py install --user

注意

如果您使用的是低于 2.6 版本的 Python,您将无法执行 --user 安装,这意味着您需要具有对 Python 安装 site-packages 目录的写入权限。

探索 Twill 语言

现在您已经安装了 Twill,您可以打开一个允许您交互式探索其语言和功能的 shell 程序。我们在这里将介绍其中一些最有用的功能。

是时候使用 Twill 浏览网页了

我们将使用 Twill 的交互式解释器来试用 Twill。

  1. 启动交互式 Twill 解释器:

    $ twill-sh
    
    

    注意

    当您启动 Twill 时,您可能会注意到关于已弃用的 md5 模块的几个警告。您可以安全地忽略它们。

  2. 获取 Twill 命令列表。您可以在提示符下输入 help <command> 来获取有关特定命令的更多信息。

    >> help
    
    

    是时候使用 Twill 浏览网页了

  3. 告诉 Twill 去访问一个网站。尽管在这个例子中使用了 slashdot.org,但鼓励读者尝试其他网站。

    >> go http://slashdot.org/
    
    

    Twill 将会打印出几行信息,表明它现在正在浏览 slashdot.org/

  4. 检查 Web 服务器是否返回了 '无错误' 状态码(也就是说,code 200)。我们同样可以检查其他状态码——例如,确保我们的界面在请求执行无效操作时返回错误。

    >> code 200
    
    
  5. 跟随一个链接,通过提供正则表达式来指定。如果你不熟悉正则表达式——甚至如果你熟悉——通常只需指定足够的链接文本来识别你想要跟随的那个链接就足够了。在跟随链接后,再次检查代码以确保它已成功执行。

    
    >> follow Science
    >> code 200
    
    
  6. 填写一个表单字段。这将第二个表单的第一个字段填入单词 monkey。在撰写本文时,第二个表单是一个搜索表单,第一个字段是搜索框。如果页面布局发生变化,这个例子可能就不再正确了。

    >> formvalue 2 1 "monkey"
    
    
  7. 我们还可以通过名称(如果它们有名称)来引用表单和表单字段。这里使用的特定表单没有名称,但字段有。以下设置与第 6 步中的命令相同的字段值,这次设置为 aardvark

    >> formvalue 2 fhfilter "aardvark"
    
    
  8. 现在,我们可以提交表单。这将 Twill 移动到新的工作 URL,并向服务器发送信息。

    >> submit
    
    
  9. 再次强调,我们想确保服务器返回了预期的代码。

    >> code 200
    
    
  10. 页面是否包含我们预期的内容?我们可以使用 find 命令来检查。在这种情况下,我们将检查两件事。第一是单词 aardvark 是否出现在结果页面的代码中。根据目前 slashdot.org 上的系统,我们可以预期它会。第二个检查,对于单词 Elephant,很可能会失败。

    
    >> find aardvark
    >> find Elephant
    
    

    行动时间 – 使用 Twill 浏览网页

刚才发生了什么?

我们使用 Twill 浏览到 slashdot.org,进入 科学 部分,搜索 aardvark,然后检查结果页面是否包含单词 aardvarkElephant。这有什么用?

我们不仅限于在 slashdot.org 上胡闹。我们可以使用 Twill 语言来描述浏览器和网站之间的任何交互。这意味着,我们可以用它来描述我们自己的网络应用程序的预期行为。如果我们能描述预期行为,我们就可以编写测试。

虽然能够将命令存储在文件中会很好,这样我们就可以自动化测试。像任何好的解释器一样,Twill 会允许我们这样做。

行动时间 – Twill 脚本

我们将编写一个 Twill 脚本,检查网站是否遵循与我们用于与 slashdot.org 交互相同的接口,然后将其应用于几个不同的网站以查看会发生什么。

  1. 创建一个名为 slashdot.twill 的文件,包含以下代码:

    code 200
    follow Science
    code 200
    formvalue 2 fhfilter "aardvark"
    submit
    code 200
    find aardvark
    
  2. 现在,我们将在 slashdot.org/ 上运行该脚本,看看它是否工作。

    $ twill-sh -u http://slashdot.org/ slashdot.twill
    
    

    行动时间 – Twill 脚本

  3. 好吧,这工作得很好。那么,让我们看看 espn.com 是否与 slashdot.org 以相同的方式工作。

    $ 
    
    twill-sh -u http://espn.com/ slashdot.twill
    
    

    行动时间 – Twill 脚本

刚才发生了什么?

通过将 Twill 命令存储在文件中,我们能够将其作为自动化测试运行。这无疑是测试我们的基于 Web 的应用程序的一大进步。

我们传递给twill-sh-u命令行选项非常有用:它具有与文件开头go命令相同的效果,但当然我们可以在每次运行脚本时更改它。如果你不确定你的 Web 应用程序的基础 URL 将是什么,这尤其有帮助。

Twill 命令

Twill 有许多命令,到目前为止我们只介绍了一些。在本节中,你将找到对 Twill 每个命令的简要讨论。

help

help命令会打印出 Twill 的所有命令列表,或者告诉你特定命令的详细信息。例如,要获取add_auth命令的详细信息,你应该输入:

>> help add_auth

help

提示

如果你想了解其他任何命令的详细语法,请使用help命令来获取这些信息。

setglobal

setglobal命令为变量名分配一个值。然后这些变量名可以用作后续命令的参数。因此,如果你告诉 Twill:

>> setglobal target http://www.example.org/ 

Twill 会将全局变量 target 设置为http://www.example.org/的值。然后你就可以说:

>> go target

告诉 Twill 转到http://www.example.org/

变量值也可以通过将变量名用${}包围插入到文本字符串中,如下所示:

>> go "${target}/example.html"

告诉 Twill 转到http://www.example.org/example.html

setlocal

setlocal命令的行为通常与setglobal命令类似,但有一个显著的区别;使用setlocal绑定的变量仅在 Twill 执行与它们绑定的相同脚本文件(或技术上,交互式会话)时存在。一旦 Twill 切换到新的脚本,局部变量就会被遗忘,直到执行返回到原始脚本。

add_auth

add_auth命令允许你通过 HTTP 的基本认证方案登录受保护的网站。该命令接受四个参数,顺序如下:realmURIusernamepassword。用户名和密码是用户为了获得对网站的访问权限而输入的。URI 是你想要应用认证的所有 Web 地址的前缀:如果你传递http://example.com/作为 URI,用户名和密码可能被用来登录example.com上的任何页面。领域是服务器选择的一个任意文本字符串,它必须包含在任何授权中。如果你正在测试自己的 Web 应用程序,你应该已经知道它是什麼。

注意

你可以在tools.ietf.org/html/rfc2617#section-2找到有关 HTTP 基本认证的所有信息。

因此,要登录到 example.com 上的示例领域,用户名为testuser,密码为12345,你会使用以下命令:

>> add_auth example http://example.com/ testuser 12345

add_extra_header

通过使用add_extra_header,你可以将任何任意 HTTP 头部包含到 Twill 后续发出的所有请求中。该命令接受两个参数:要添加的头部字段名称和分配给头部字段的值。

你需要记住,HTTP 允许在同一个请求中存在相同的头部多次,并且每次有不同的值。如果你告诉 Twill

>> add_extra_header moose 12
>> add_extra_header moose 15

那么每个请求中都会发送两个'moose'头部,具有不同的值。

clear_extra_headers

clear_extra_headers命令从未来的请求中删除之前定义的所有额外头部。删除的头部可以在以后重新添加。

show_extra_headers

show_extra_headers命令打印出所有当前添加的额外头部及其值。

agent

你可以使用agent命令使 Twill 伪装成不同的网页浏览器。你可以使用任何用户代理字符串作为参数。在撰写本文时,user-agent-string.info/是一个有用的资源,可以找到网页浏览器使用的用户代理字符串。

back

back命令的工作方式与网页浏览器上的后退按钮相同,它会返回到 Twill 历史记录中最新的 URL。

clear_cookies

clear_cookies命令会导致 Twill 忘记其当前存储的所有 cookies。

code

code命令检查上一个导航命令的 HTTP 响应代码是否是预期的值。表示“成功”的值是200404表示页面未找到,401表示在浏览页面之前需要登录,301302是重定向,等等。

注意

你可以在tools.ietf.org/html/rfc2616#section-6.1.1.找到官方 HTTP 响应代码的完整列表。

config

config命令允许你修改 Twill 解释器的行为。它接受配置参数名称和整数值作为参数,Twill 根据配置变量的值修改其行为。

要获取当前配置变量的完整列表,请输入:


>> help config

debug

debug命令会导致 Twill 在操作时输出跟踪信息。在撰写本文时,有三种不同的调试跟踪可用:HTTP、命令和 HTTP-EQUIV 刷新标签的处理。

如果你告诉 Twill:

>> debug http 1

当 Twill 执行 HTTP 操作时,你会看到请求和响应行以及随响应返回的 HTTP 头部字段。

debug commands 1命令在直接与 Twill 解释器交互时没有用,但如果你将其放在 Twill 脚本中,它将导致 Twill 在执行每个命令时打印出来,这样你就可以看到脚本在做什么。

如果你告诉 Twill:

>> debug equiv-refresh 1

当它遇到带有<META HTTP-EQUIV="refresh"...>标签的页面时,它将打印出额外信息。

echo

echo 命令在你想要你的 Twill 脚本输出信息,但又觉得任何 debug 子命令都没有真正达到你的目的时很有用。你传递给 echo 的任何参数都会打印到屏幕上。

exit

exit 命令会导致 Twill 解释器终止。它接受一个错误代码——它只是一个整数,通常将 0 解释为 '无错误'——作为可选参数。即使你向 exit 传递非零值,Twill 也会在所有它运行的命令执行正确后打印出脚本成功,包括 exit。错误代码仅在执行 Twill 的程序使用它时才有意义,所以在许多情况下它将被完全忽略。

extend_with

extend_with 命令是自定义 Twill 解释器的一种机制。它导入一个 Python 模块,并将其中任何函数添加为新的 Twill 命令。

find

find 命令在当前页面中搜索与正则表达式匹配的文本。Python 的正则表达式语法在在线文档 docs.python.org/library/re.html#regular-expression-syntax 中描述,但就我们的目的而言,只需知道如果你输入一个单词,find 将会查找它。

find 命令还接受一个可选的第二个参数。该参数是一个表示控制搜索如何执行的选项的文本字符串。如果字符串包含字母 i,则搜索是不区分大小写的,这意味着大写和小写字母可以相互匹配。字母 ms 分别表示使用 'MULTILINE' 和 'DOTALL' 模式。这些模式在上面的文档中有描述。

find 命令还将匹配的文本绑定到局部变量名 __match__,这样你就可以在后续命令中引用它,就像它已经被 setlocal 设置过一样。

notfind

notfind 命令与 find 命令类似,但如果有正则表达式的匹配项,它会失败。如果没有找到匹配项,它会成功。

follow

follow 命令在当前页面中搜索与正则表达式匹配的链接,并转到链接地址。使用 follow 就像在普通网页浏览器中点击链接一样。

find 命令不同,follow 命令不接受正则表达式标志,也不绑定 __match__ 名称。它只是跟随超链接指向的地方。

formaction

formaction 命令允许你更改表单提交的地址。它接受两个参数:你想要更改的表单的标识符,以及你想要表单提交到的 URL。

例如,以下 HTML 会生成一个表单,该表单将被提交到当前 URL,因为当 form 标签中省略了 action 属性时,这是默认行为:

<form name="form1" method="post">

执行此 formaction 命令后,

>> formaction form1 http://example.com/viewer 

就好像表单已经被写入:

<form name="form1" method="post" action="http://example.com/viewer">

formclear

formclear 命令将表单重置为其初始状态,这意味着其他命令输入的数据将被遗忘。

formfile

formfile 命令为 <input type="file"> 表单字段填充值。它有三个必需的参数:表单的名称或编号、字段的名称或编号以及文件的文件名。可选地,可以添加第四个参数,指定文件的 mime 内容类型。

form value

formvalue 命令为 HTML 表单字段填充值。它接受三个参数:表单的名称或编号、字段的名称或编号以及要分配的值。我们在上面的示例 Twill 脚本中使用了 formvalue

getinput

getinput 命令允许 Twill 脚本具有交互性。该命令接受一个参数,即将在用户界面显示的提示。在打印提示后,Twill 等待用户输入一些内容并按回车键,之后用户输入的内容将被存储在名为 __input__ 的本地变量中。

getpassword

getpassword 命令的工作方式与 getinput 类似。不同之处在于 getpassword 不会显示用户输入的文本,并且输入的文本在输入后绑定到本地变量名称 __password__

go

go 命令指示 Twill 前往新的 URL 并加载该地址的页面。与 follow 不同,go 不关心当前页面上的链接。使用 go 就像在普通网络浏览器的地址栏中输入地址一样。

info

info 命令打印 Twill 当前浏览的页面的一些一般信息。这些信息包括 URL、HTTP 状态码、页面的 MIME 内容类型、标题以及页面上的表单数量。

save_cookies

save_cookies 命令保存 Twill 当前知道的任何 cookies 的副本。这些 cookies 可以稍后重新加载。该命令接受一个参数:存储 cookies 的文件名。

load_cookies

load_cookies 命令用文件中存储的 cookies 替换 Twill 当前知道的任何 cookies。它接受一个参数:要加载的 cookie 文件名。

show_cookies

show_cookies 命令将打印出 Twill 当前知道的任何 cookies。

redirect_error

redirect_error 命令会导致 Twill 的所有错误消息被存储在文件中,而不是打印到屏幕上。它接受一个参数,表示存储错误的文件名。

redirect_output

redirect_output 命令会导致 Twill 将所有正常输出保存到文件中,而不是打印到屏幕上。它接受一个参数,表示存储输出的文件名。

这不是一个在交互式 Twill 壳中非常有用的命令。它在脚本和测试中可能很有用。

reset_error

reset_error 命令撤销 redirect_error 的效果。

reset_output

reset_output 命令撤销 redirect_output 的效果。

reload

reload 命令重新加载当前 URL,就像正常网络浏览器中的重新加载或刷新按钮一样。

reset_browser

reset_browser 命令销毁与当前 Twill 会话相关的所有状态信息。它具有停止 Twill 然后再次启动的效果。

run

run 命令执行任意的 Python 语句。唯一的参数是要执行的 Python 语句。如果语句包含空格,则必须将其放在引号内,这样 Twill 就不会将其误认为是多个参数。

runfile

runfile 命令执行存储在单独文件中的 Twill 脚本。执行后的脚本将有自己的局部命名空间(参看 setlocal 命令),并且将与全局命名空间共享(参看 setglobal

save_html

save_html 命令将当前页面的 HTML 内容保存到文件中。它接受一个可选参数,指定要保存的文件名。如果没有指定文件名,Twill 将根据要保存的数据的 URL 自行选择。

show

show 命令打印出当前页面的 HTML 内容。在交互式会话中,这可以用来了解 Twill 看到了什么,偶尔在测试脚本中也有用,如果您想确保页面具有精确指定的内容。

showforms

showforms 命令会打印出当前页面中所有表单的列表。每个表单都有一个包含表单编号(以及如果有名称则还包括名称)的打印输出,以及每个字段的编号、名称、类型、id 和当前值。

showhistory

showhistory 命令按顺序从最老到最新打印出当前 Twill 会话中访问过的所有 URL。

showlinks 命令会打印出当前页面中的链接列表(可能相当长)。这有助于确定需要输入到 follow 命令中的内容,或者只是用于一般的调试。

sleep

sleep 命令可以在 Twill 脚本的执行中注入暂停。它接受一个可选参数,指定在继续执行脚本之前暂停的秒数。如果没有指定时间,则默认为 一秒。

submit

submit 命令提交由 formvalue 命令最近更改的字段的表单。它接受一个可选参数,指定要使用的 submit 按钮,其指定方式与为 formvalue 命令指定的字段相同。如果没有指定 submit 按钮,则使用表单中的第一个。

tidy_ok

如果您已安装 HTML Tidy (tidy.sourceforge.net/),则 tidy_ok 命令将使用它来检查当前页面的代码是否正确。如果您在脚本中放置 tidy_ok 并当前页面不符合 Tidy 的正确性标准,则脚本将被视为失败。

title

title 命令接受一个正则表达式作为其唯一参数,并尝试将当前页面的标题与正则表达式匹配。如果它们不匹配,title 命令将失败。在脚本文件中使用时,如果标题不匹配,这将导致整个脚本被视为失败。

url

url 命令接受一个正则表达式作为其唯一参数,并尝试将当前页面的 URL 与正则表达式匹配。如果它们不匹配,url 命令将失败,并导致它所属的脚本失败。如果正则表达式与 URL 匹配,局部变量 __match__ 将绑定到匹配的 URL 部分。

快速问答 – Twill 语言

  1. 当你使用 submit 命令时,会提交哪种表单?

  2. 你会用哪个命令来检查错误消息是否在页面上?

  3. 当你执行 Twill 脚本且命令失败时,会发生什么?

尝试一下英雄 – 使用 Twill 浏览网页

打开一个 Twill 交互式壳,使用它来搜索 Google,跟随搜索结果中的一个链接,并在链接的网站上导航。当你这样做的时候,尽量尝试使用尽可能多的 Twill 命令来获得一些实际操作经验。

从测试中调用 Twill 脚本

虽然 twill-sh 能够执行多个 Twill 脚本作为自动化测试的一种形式是件好事,但我们更希望能够在我们的正常测试套件中运行 Twill 脚本。幸运的是,这样做相当简单。有两种很好的方法可以从 Python 代码中运行 Twill 脚本,你可以选择更适合你需求的一种。

执行 Twill 脚本文件的时间 – 运行 Twill 脚本文件

第一种方法是将 Twill 脚本存储在单独的文件中,然后使用 twill.parse.execute_file 函数来运行它。

  1. 将以下代码放入一个名为 fail.twill 的文件中:

    go http://slashdot.org/
    find this_does_not_exist
    
  2. 自然,这个脚本会失败,但请用 twill-sh 运行它,亲自看看。

    $ twill-sh fail.twill
    
    
  3. 现在从 Python 运行脚本。打开一个交互式 Python 壳,执行以下操作:

    >>> from twill.parse import execute_file
    >>> execute_file('fail.twill')
    
    

    执行 Twill 脚本文件的时间 – 运行 Twill 脚本文件

  4. 简单到这种程度,我们从 Python 代码内部运行了脚本。这在 doctest、unittest 或 nose 特定的测试代码中同样有效。

  5. 注意,Twill 壳会报告为错误的,execute_file 会报告为 twill.errors.TwillAssertionError 异常。这很好地与之前讨论过的自动化测试工具集成在一起。

刚才发生了什么?

只用几行代码,我们就执行了一个存储在单独文件中的 Twill 脚本,并接收到了它遇到的任何错误作为 Python 异常。这对于你有一个现有的 Twill 脚本,只想让它与你的测试套件一起运行的情况非常理想。如果你想要自动生成 Twill 脚本,或者你只是想将不同的语言保存在不同的文件中,这也非常方便。

执行 Twill 脚本字符串的时间

从 Python 代码内部运行 Twill 脚本的第二种方式是将脚本存储在字符串中。

  1. 打开一个交互式 Python 解释器,并输入以下命令:

    >>> from twill.parse import execute_string
    >>> execute_string("""
    ... go http://slashdot.org/
    ... find this_does_not_exist
    ... """, no_reset = False)
    
    
  2. 结果将与执行包含这些命令的文件相同。

    注意

    注意我们传递给 execute_stringno_reset = False 参数。我们需要它,因为如果我们省略它,Twill 将假设我们所有的 execute_string 调用都应该执行,就像它们都是同一个浏览器会话的一部分一样。我们不想这样,因为我们希望我们的测试彼此分离。execute_file 将做出相反的假设,因此我们不需要传递 no_reset 参数(尽管我们可以)。

发生了什么?

这次,脚本被直接嵌入到 Python 代码中作为一个字符串常量。当 Twill 脚本被视为编写测试部分的一种方式,而不是一个独立的事物时,这是所希望的。

一个巧妙的技巧

如果你正在使用 Python 2.4 或更高版本,你可以定义一个函数装饰器,这使得将 Twill 测试编写为 Python 函数变得简单。

from twill.parse import execute_string
from twill.errors import TwillAssertionError

def twill_test(func):
    def run_test(*args):
        try:
            execute_string(func.__doc__, no_reset = False)
        except TwillAssertionError, err:
            if args and hasattr(args[0], 'fail'):
                args[0].fail(str(err))
            else:
                raise
    return run_test

如果你将这段代码放入一个 Python 模块(这里称为 twill_decorator)中,然后导入 twill_test 到你的测试代码中,你可以这样编写 Twill 测试:

from unittest import TestCase
from twill_decorator import twill_test

class web_tests(TestCase):
    @twill_test
    def test_slashdot(self):
        """
        go http://slashdot.org/ 
        find this_does_not_exist
        """

当你使用 Nose 或 unittest 运行该测试模块时,test_slashdot 函数将自动执行其文档字符串中的 Twill 脚本,并将任何错误报告为测试失败。你不需要记住传递 no_reset = False 或运行 Twill 从字符串的任何其他细节。

将 Twill 操作集成到 unittest 测试中

到目前为止,我们的单元测试将每个 Twill 脚本视为一个产生成功或失败的单个操作。如果我们想下载一个 HTML 页面,对其内容与数据库之间的关系进行一些断言,然后跟随链接到另一个页面,会怎样呢?

我们可以通过直接从测试代码中访问 Twill 的浏览器对象来做这样的事情。浏览器对象具有类似于 Twill 语言命令的方法,所以这应该看起来相当熟悉。

行动时间 - 使用 Twill 的浏览器对象

在这里,我们看到如何直接访问浏览器对象,并使用它来与网络交互。

  1. 将以下代码放入一个 Python 测试模块中:

    from unittest import TestCase
    import twill
    
    class test_twill_browser(TestCase):
        def test_slashdot(self):
            browser = twill.get_browser()
    
            browser.go('http://slashdot.org/')
            self.assertTrue(browser.get_code() in (200, 201))
    
            html = browser.get_html()
            self.assertTrue(html.count('slashdot') > 150)
    
            link = browser.find_link('Science')
            browser.follow_link(link)
    
            form = browser.get_form(2)
            form.set_value('aardvark', name = 'fhfilter')
            browser.clicked(form, None)
            browser.submit()
            self.assertEqual(browser.get_code(), 200)
    
  2. 使用 nosetests 运行测试模块。如果 Slashdot 自从本文编写以来没有更改其界面,那么测试将通过。如果他们已经更改,测试可能会失败。

发生了什么?

我们不是使用 Twill 语言来描述与网站的交互,而是将 Twill 作为我们可以从测试代码中调用的库。这允许我们将 Twill 操作与 unittest 断言交织在一起。我们还可以包括任何其他我们需要的操作。使用这种技术,我们的测试可以将网络视为它们可以访问的更多数据源之一。

重要的是要注意 Twill 语言和浏览器对象上可用的方法之间的差异。例如,Twill 语言有一个show命令,用于打印当前页面的 HTML,而浏览器有一个get_html方法,用于返回当前页面的 HTML。

在测试的最后,特别关注与表单的交互。这些交互使用表单对象,可以通过调用浏览器对象的get_form方法来检索。

表单对象的set_value方法接受控制的新值作为第一个参数,然后有多个关键字参数可以用来指定哪个控件应该采用该值。其中最有用的参数是name,如上所述,以及nr,它通过数字选择控件。

为了使submit方法工作,它应该由一个调用clicked方法的目标表单控件(无论哪个)的方法来 precede。

浏览器方法

使用twill.get_browser()获取的浏览器对象具有以下有用的方法:

  • go

  • reload

  • back

  • get_code

  • get_html

  • get_title

  • get_url

  • find_link

  • follow_link

  • set_agent_string

  • get_all_forms

  • get_form

  • get_form_field

  • clicked

  • submit

  • save_cookies

  • load_cookies

  • clear_cookies

许多这些方法与相关的 Twill 命令工作方式相同,除了你将参数作为字符串传递给方法调用[例如browser.save_cookies('cookies.txt')]。尽管如此,有一些方法的行为不同,或者没有 Twill 语言的等效项,所以我们现在将更详细地介绍这些方法:

get_code

get_code方法返回当前页面的 HTTP 代码。它不会在代码和预期值之间进行任何比较。如果你想如果代码不是200就抛出异常,你需要自己来做。

get_html

get_html方法返回当前页面的 HTML 作为 Python 字符串。

get_title

get_title方法返回当前页面的标题作为 Python 字符串。

get_url

get_url方法返回当前页面的 URL 作为 Python 字符串。

find_link方法搜索一个 URL、文本或名称与传入的参数匹配的链接。如果找到这样的链接,它将返回表示该链接的对象。如果没有这样的链接,find_link返回None

链接对象具有许多有用的属性。如果你有一个名为link的链接对象,那么link.attrs是一个包含(name, value)元组的列表,link.text是出现在<a></a>标签之间的文本,而link.absolute_url是链接指向的地址。

follow_link方法接受一个链接对象作为参数,并跳转到链接表示的地址。如果你有一个字符串形式的 URL 而不是链接对象,你应该使用go方法。

get_all_forms

get_all_forms 方法返回一个表示页面中所有表单的表单对象列表。如果页面上有不在 <form> 标签内的表单控件,将创建一个特殊的表单对象来包含它们,并且将成为列表的第一个元素。

get_form

get_form 方法接受一个正则表达式作为参数,并搜索一个其 id、name 或编号匹配的表单。如果找到这样的表单,它将返回一个表示该表单的表单对象。

表单对象有几个有用的属性。如果你有一个名为 form 的表单对象,那么 form.name 是表单的名称(如果有),form.method 是表单的方法(通常是 'GET' 或 'POST'),form.action 是表单应该提交到的 URL,form.enctype 是编码表单用于传输的内容类型,而 form.attrs 是应用于表单的属性字典。

表单对象还有帮助您操作其内容的方法。其中最值得注意的是 form.get_valueform.set_valueform.clearform.clear_allform.add_file。除了 clear_all 之外的所有这些方法都针对表单中的特定控件。您可以通过向方法传递一个或多个以下关键字参数来指定要针对哪个控件:nametypekindidnrlabelnr 关键字是 'number' 的简称。如果没有控件匹配所有指定的参数,将引发 _mechanize_dist.ClientForm.ControlNotFoundError 异常。

set_valueadd_file 方法分别接受一个值或一个文件名作为它们的第一个参数。get_value 方法返回所选控件的当前值。clear 方法将控件返回到其默认值。

get_form_field

get_form_field 方法接受一个表单对象作为其第一个参数,一个正则表达式作为其第二个参数。如果表单的控件中恰好有一个 id、name 或 index 与正则表达式匹配,则返回表示该控件的对象。

在大多数情况下,这并不是必需的,因为表单对象的方法是更灵活地操作表单控件的方式。它的主要用途是向 clicked 方法提供输入。

clicked

clicked 方法存在是为了让浏览器对象了解当前页面的哪个部分是当前焦点。特别是,当调用 submit 方法时,它会告诉浏览器应该提交哪个表单。

clicked 方法接受两个参数:将成为焦点的表单对象,以及表单中应该注册点击的具体控件。

通常最简单的方法是将 None 作为特定控件传递。然而,你也可以传递一个控件对象(由 get_form_field 返回)。如果这个控件对象代表一个提交控件,那么该控件将成为提交表单时使用的新默认控件。初始默认值是表单中的第一个提交控件。

submit

submit 方法提交最后点击的表单,按照其 actionmethod 属性。你可以选择性地传递一个 fieldname 参数,表示要使用哪个提交控件进行提交。如果存在,此参数将被传递给 get_form_field 以找到适当的提交控件。如果你没有向该方法传递 fieldname,则将使用默认的提交控件。

突击测验 – 浏览器方法

  1. 当你调用 get_form 时,如何指示你想要检索哪个表单对象?

  2. clicked 方法的作用是什么?

  3. get_code 方法与 code 命令有何不同?

摘要

在本章中,我们学到了很多关于 Twill 的知识,以及如何使用它来编写 Web 应用程序的测试。

具体来说,我们涵盖了:

  • Twill 语言

  • 从 Python 测试中调用 Twill 脚本

  • 将 Twill 的功能作为库集成到 Python 测试代码中

现在我们已经了解了测试 Web 应用程序,我们可以继续讨论集成测试和系统测试——这是下一章的主题。

第九章。集成测试和系统测试

到目前为止,我们已经讨论了所有的工具、技术和实践,但我们仍然只是在思考测试单元:代码中最小且具有实际测试意义的部分。现在是时候扩大关注范围,开始测试包含多个单元的代码了。

在本章中,我们将:

  • 描述集成测试和系统测试

  • 学习如何将程序分解成可测试的多单元部分

  • 使用 doctest、unittest 和 Nose 来自动化多单元测试

那么,让我们开始吧!

集成测试和系统测试

集成测试是检查构成您程序的单元是否能够与其他单元正确协作工作,而不是单独工作。在集成测试开始之前启动这个过程并不实际,因为如果单元不工作,集成也不会工作,而且追踪问题的原因会更困难。然而,一旦单元稳固,测试您从中构建的东西是否也工作就是必要的。单元之间的交互可能会令人惊讶。

当您执行集成测试时,您将把单元组合成更大和更大的集合,并测试这些集合。当您的集成测试扩展到覆盖整个程序时,它们就变成了系统测试。

集成测试中最棘手的部分是选择将哪些单元集成到每个测试中,以便您始终有一个可以信赖的稳固代码基础;一个可以立足的地方,在您引入更多代码的同时。

行动时间 - 确定集成顺序

我们将通过一个练习来帮助确定集成测试边界的放置过程。

  1. 使用一张纸或图形程序,为第七章中的时间规划项目中的每个单元写出名称或表示。将每个类的方法定组。同一类中的单元之间存在明显的关联,我们将利用这一点。(这里的==符号代表 Python 的==运算符,它在对象上调用__eq__方法)。行动时间 - 确定集成顺序

  2. 现在,在应该直接相互交互的单元之间画箭头,从调用者到被调用者。将所有内容有序地排列(就像步骤 1 中那样)实际上可能会使这个过程变得更难,所以请随意移动类以帮助线条有意义。行动时间 - 确定集成顺序

  3. 在每个类和至少通过一条线连接的每一对类周围画圆圈。行动时间 - 确定集成顺序

  4. 通过在重叠的圆圈对周围画圆,继续这个过程,直到只剩下三个圆圈。画出一对圆圈,然后在整个混乱中再画一个更大的圆圈。行动时间 – 确定积分顺序

  5. 这些圆圈告诉我们应该按什么顺序编写我们的集成测试。圆圈越小,测试应该越早编写。

刚才发生了什么?

我们刚才做的是一种可视化和具体化构建集成测试过程的方法。虽然实际上画线和圆圈不是必要的,但在脑海中跟随这个过程是有用的。对于较大的项目,实际绘制图表可以获得很多好处。当你看到图表时,正确的下一步通常会立即显现出来——特别是如果你使用多种颜色来渲染图表——否则它可能隐藏在程序的复杂性背后。

快速问答 - 绘制集成图

  1. 在这个过程的早期阶段,将单元组合成类有什么意义?

  2. 当我们移动类以帮助箭头有意义时,这对后续过程有什么影响?

  3. 为什么我们总是在做这个的时候总是将单元组合在一起?

尝试自己绘制程序图

拿你自己的程序之一,并为它绘制一个集成图。如果你的程序足够大,以至于图表开始变得笨拙,尝试将图表的不同“级别”放在单独的页面上。

使用 doctest、unittest 和 Nose 进行自动化

集成测试和单元测试之间唯一的真正区别在于,在集成测试中,你可以将正在测试的代码分解成更小的、有意义的块。在单元测试中,如果你再细分代码,它就不再有意义了。因此,帮助自动化单元测试的工具也可以应用于集成测试。由于系统测试实际上是集成测试的最高级别,因此这些工具也可以用于此。

doctest 在集成测试中的作用通常相当有限。doctest 的真正优势在于开发过程的早期阶段。一个可测试的规范很容易进入集成测试——如前所述,只要有单元测试,那就没问题——但之后你可能会更喜欢 unittest 和 Nose 来编写你的集成测试。

集成测试需要彼此隔离。尽管它们在自身内部包含多个相互作用的单元,但你仍然受益于知道测试之外没有东西在影响它。因此,unittest 是编写自动化集成测试的好选择。与 unittest、Nose 和 Mocker 一起工作,可以很好地完成这个画面。

行动时间 – 为时间规划器编写集成测试

现在我们已经为时间规划器代码绘制了集成图,我们可以继续编写实际的自动化集成测试。

  1. 整合图仅提供了集成测试的部分排序,并且有几个测试可能是我们首先编写的。查看图表,我们看到statusesactivities类位于许多箭头的末端,但不在任何箭头的起始处。这使得它们成为特别好的起点,因为这意味着它们不需要调用自身之外的东西来操作。由于没有东西能区分它们中哪一个比另一个更适合作为起点,我们可以任意选择它们。让我们从statuses开始,然后进行activities。我们将编写测试来测试整个类。在这个低级别上,集成测试将非常类似于同一类的单元测试,但我们不会使用模拟对象来表示同一类的其他实例:我们将使用真实实例。我们正在测试类是否能够正确地与自身交互。

  2. 下面是statuses的测试代码:

    from unittest import TestCase
    from planner.data import statuses, task_error
    from datetime import datetime
    
    class statuses_integration_tests(TestCase):
        def setUp(self):
            self.A = statuses('A',
                              datetime(year=2008, month=7, day=15),
                              datetime(year=2009, month=5, day=2))
    
        def test_repr(self):
            self.assertEqual(repr(self.A), '<A 2008-07-15T00:00:00 2009-05-02T00:00:00>')
    
        def test_equality(self):
            self.assertEqual(self.A, self.A)
            self.assertNotEqual(self.A, statuses('B',
                              datetime(year=2008, month=7, day=15),
                              datetime(year=2009, month=5, day=2)))
            self.assertNotEqual(self.A, statuses('A',
                              datetime(year=2007, month=7, day=15),
                              datetime(year=2009, month=5, day=2)))
            self.assertNotEqual(self.A, statuses('A',
                              datetime(year=2008, month=7, day=15),
                              datetime(year=2010, month=5, day=2)))
    
        def test_overlap_begin(self):
            status = statuses('status name',
                              datetime(year=2007, month=8, day=11),
                              datetime(year=2008, month=11, day=27))
    
            self.assertTrue(status.overlaps(self.A))
    
        def test_overlap_end(self):
            status = statuses('status name',
                              datetime(year=2008, month=1, day=11),
                              datetime(year=2010, month=4, day=16))
    
            self.assertTrue(status.overlaps(self.A))
    
        def test_overlap_inner(self):
            status = statuses('status name',
                              datetime(year=2007, month=10, day=11),
                              datetime(year=2010, month=1, day=27))
    
            self.assertTrue(status.overlaps(self.A))
    
        def test_overlap_outer(self):
            status = statuses('status name',
                              datetime(year=2008, month=8, day=12),
                              datetime(year=2008, month=9, day=15))
    
            self.assertTrue(status.overlaps(self.A))
    
        def test_overlap_after(self):
            status = statuses('status name',
                              datetime(year=2011, month=2, day=6),
                              datetime(year=2015, month=4, day=27))
    
            self.assertFalse(status.overlaps(self.A))
    
  3. 下面是activities的测试代码:

    from unittest import TestCase
    from planner.data import activities, task_error
    from datetime import datetime
    
    class activities_integration_tests(TestCase):
        def setUp(self):
            self.A = activities('A',
                              datetime(year=2008, month=7, day=15),
                              datetime(year=2009, month=5, day=2))
        def test_repr(self):
            self.assertEqual(repr(self.A), '<A 2008-07-15T00:00:00 2009-05-02T00:00:00>')
    
        def test_equality(self):
            self.assertEqual(self.A, self.A)
            self.assertNotEqual(self.A, activities('B',
                              datetime(year=2008, month=7, day=15),
                              datetime(year=2009, month=5, day=2)))
            self.assertNotEqual(self.A, activities('A',
                              datetime(year=2007, month=7, day=15),
                              datetime(year=2009, month=5, day=2)))
            self.assertNotEqual(self.A, activities('A',
                              datetime(year=2008, month=7, day=15),
                              datetime(year=2010, month=5, day=2)))
    
        def test_overlap_begin(self):
            activity = activities('activity name',
                              datetime(year=2007, month=8, day=11),
                              datetime(year=2008, month=11, day=27))
    
            self.assertTrue(activity.overlaps(self.A))
            self.assertTrue(activity.excludes(self.A))
    
        def test_overlap_end(self):
            activity = activities('activity name',
                              datetime(year=2008, month=1, day=11),
                              datetime(year=2010, month=4, day=16))
    
            self.assertTrue(activity.overlaps(self.A))
            self.assertTrue(activity.excludes(self.A))
    
        def test_overlap_inner(self):
            activity = activities('activity name',
                              datetime(year=2007, month=10, day=11),
                              datetime(year=2010, month=1, day=27))
    
            self.assertTrue(activity.overlaps(self.A))
            self.assertTrue(activity.excludes(self.A))
    
        def test_overlap_outer(self):
            activity = activities('activity name',
                              datetime(year=2008, month=8, day=12),
                              datetime(year=2008, month=9, day=15))
    
            self.assertTrue(activity.overlaps(self.A))
            self.assertTrue(activity.excludes(self.A))
    
        def test_overlap_after(self):
            activity = activities('activity name',
                              datetime(year=2011, month=2, day=6),
                              datetime(year=2015, month=4, day=27))
    
            self.assertFalse(activity.overlaps(self.A))
    
  4. 查看我们的图表,我们可以看到从statusesactivities延伸出的下一级代表了这些类与schedules类的集成。在我们编写集成之前,我们应该编写任何涉及schedules类与自身交互的测试,而不使用模拟。

    from unittest import TestCase
    from mocker import MockerTestCase, MATCH, ANY
    from planner.data import schedules, schedule_error
    from datetime import datetime
    
    class schedules_tests(MockerTestCase):
        def setUp(self):
            mocker = self.mocker
    
            A = mocker.mock()
            A.__eq__(MATCH(lambda x: x is A))
            mocker.result(True)
            mocker.count(0, None)
            A.__eq__(MATCH(lambda x: x is not A))
            mocker.result(False)
            mocker.count(0, None)
            A.overlaps(ANY)
            mocker.result(False)
            mocker.count(0, None)
            A.begins
            mocker.result(5)
            mocker.count(0, None)
    
            B = mocker.mock()
            A.__eq__(MATCH(lambda x: x is B))
            mocker.result(True)
            mocker.count(0, None)
            B.__eq__(MATCH(lambda x: x is not B))
            mocker.result(False)
            mocker.count(0, None)
            B.overlaps(ANY)
            mocker.result(False)
            mocker.count(0, None)
            B.begins
            mocker.result(3)
            mocker.count(0, None)
    
            C = mocker.mock()
            C.__eq__(MATCH(lambda x: x is C))
            mocker.result(True)
            mocker.count(0, None)
            C.__eq__(MATCH(lambda x: x is not C))
            mocker.result(False)
            mocker.count(0, None)
            C.overlaps(ANY)
            mocker.result(False)
            mocker.count(0, None)
            C.begins
            mocker.result(7)
            mocker.count(0, None)
    
            self.A = A
            self.B = B
            self.C = C
    
            mocker.replay()
    
        def test_equality(self):
            sched1 = schedules()
            sched2 = schedules()
    
            self.assertEqual(sched1, sched2)
    
            sched1.add(self.A)
            sched1.add(self.B)
    
            sched2.add(self.A)
            sched2.add(self.B)
            sched2.add(self.C)
    
            self.assertNotEqual(sched1, sched2)
    
            sched1.add(self.C)
    
            self.assertEqual(sched1, sched2)
    
  5. 现在,schedules类内部的交互已经测试过了,我们可以编写将schedulesstatusesactivities之一集成的测试。让我们从statuses开始,然后进行activities。以下是schedulesstatuses的测试:

    from planner.data import schedules, statuses
    from unittest import TestCase
    from datetime import datetime, timedelta
    
    class test_schedules_and_statuses(TestCase):
        def setUp(self):
            self.A = statuses('A',
                             datetime.now(),
                             datetime.now() + timedelta(minutes = 7))
            self.B = statuses('B',
                             datetime.now() - timedelta(hours = 1),
                             datetime.now() + timedelta(hours = 1))
            self.C = statuses('C',
                             datetime.now() + timedelta(minutes = 10),
                             datetime.now() + timedelta(hours = 1))
    
        def test_usage_pattern(self):
            sched = schedules()
    
            sched.add(self.A)
            sched.add(self.C)
    
            self.assertTrue(self.A in sched)
            self.assertTrue(self.C in sched)
            self.assertFalse(self.B in sched)
    
            sched.add(self.B)
    
            self.assertTrue(self.B in sched)
    
            self.assertEqual(sched, sched)
    
            sched.remove(self.A)
    
            self.assertFalse(self.A in sched)
            self.assertTrue(self.B in sched)
            self.assertTrue(self.C in sched)
    
            sched.remove(self.B)
            sched.remove(self.C)
    
            self.assertFalse(self.B in sched)
            self.assertFalse(self.C in sched)
    
  6. 下面是schedulesactivities的测试:

    from planner.data import schedules, activities, schedule_error
    from unittest import TestCase
    from datetime import datetime, timedelta
    
    class test_schedules_and_activities(TestCase):
        def setUp(self):
            self.A = activities('A',
                              datetime.now(),
                              datetime.now() + timedelta(minutes = 7))
            self.B = activities('B',
                              datetime.now() - timedelta(hours = 1),
                              datetime.now() + timedelta(hours = 1))
            self.C = activities('C',
                              datetime.now() + timedelta(minutes = 10),
                              datetime.now() + timedelta(hours = 1))
    
        def test_usage_pattern(self):
            sched = schedules()
    
            sched.add(self.A)
            sched.add(self.C)
    
            self.assertTrue(self.A in sched)
            self.assertTrue(self.C in sched)
            self.assertFalse(self.B in sched)
    
            self.assertRaises(schedule_error, sched.add, self.B)
    
            self.assertFalse(self.B in sched)
            self.assertEqual(sched, sched)
    
            sched.remove(self.A)
    
            self.assertFalse(self.A in sched)
            self.assertFalse(self.B in sched)
            self.assertTrue(self.C in sched)
    
            sched.remove(self.C)
    
            self.assertFalse(self.B in sched)
            self.assertFalse(self.C in sched)
    
  7. 现在是时候将schedulesstatusesactivities全部整合到同一测试中。

    from planner.data import schedules, statuses, activities, schedule_error
    from unittest import TestCase
    from datetime import datetime, timedelta
    
    class test_schedules_activities_and_statuses(TestCase):
        def setUp(self):
            self.A = statuses('A',
                              datetime.now(),
                              datetime.now() + timedelta(minutes = 7))
            self.B = statuses('B',
                              datetime.now() - timedelta(hours = 1),
                              datetime.now() + timedelta(hours = 1))
            self.C = statuses('C',
                             datetime.now() + timedelta(minutes = 10),
                             datetime.now() + timedelta(hours = 1))
    
            self.D = activities('D',
                              datetime.now(),
                              datetime.now() + timedelta(minutes = 7))
    
            self.E = activities('E',
                              datetime.now() + timedelta(minutes=30),
                              datetime.now() + timedelta(hours=1))
    
            self.F = activities('F',
                              datetime.now() - timedelta(minutes=20),
                              datetime.now() + timedelta(minutes=40))
    
        def test_usage_pattern(self):
            sched = schedules()
    
            sched.add(self.A)
            sched.add(self.B)
            sched.add(self.C)
    
            sched.add(self.D)
    
            self.assertTrue(self.A in sched)
            self.assertTrue(self.B in sched)
            self.assertTrue(self.C in sched)
            self.assertTrue(self.D in sched)
    
            self.assertRaises(schedule_error, sched.add, self.F)
            self.assertFalse(self.F in sched)
    
            sched.add(self.E)
            sched.remove(self.D)
    
            self.assertTrue(self.E in sched)
            self.assertFalse(self.D in sched)
    
            self.assertRaises(schedule_error, sched.add, self.F)
    
            self.assertFalse(self.F in sched)
    
            sched.remove(self.E)
    
            self.assertFalse(self.E in sched)
    
            sched.add(self.F)
    
            self.assertTrue(self.F in sched)
    
  8. 下一步需要整合的是file类,但在将其与其他系统整合之前,我们需要将其与自身整合;不使用模拟对象检查其内部交互。

    from unittest import TestCase
    from planner.persistence import file
    from os import unlink
    
    class test_file(TestCase):
        def setUp(self):
            storage = file('file_test.sqlite')
    
            storage.store_object('tag1', 'A')
            storage.store_object('tag2', 'B')
            storage.store_object('tag1', 'C')
            storage.store_object('tag1', 'D')
            storage.store_object('tag3', 'E')
            storage.store_object('tag3', 'F')
    
        def tearDown(self):
            unlink('file_test.sqlite')
    
        def test_other_instance(self):
            storage = file('file_test.sqlite')
    
            self.assertEqual(set(storage.load_objects('tag1')),
                             set(['A', 'C', 'D']))
    
            self.assertEqual(set(storage.load_objects('tag2')),
                             set(['B']))
    
            self.assertEqual(set(storage.load_objects('tag3')),
                             set(['E', 'F']))
    
  9. 现在我们可以编写将schedulesfile集成的测试。注意,对于这一步,我们仍然没有涉及statusesactivities,因为它们在椭圆形之外。

    from mocker import Mocker, MockerTestCase, ANY
    from planner.data import schedules
    from planner.persistence import file
    from os import unlink
    
    def unpickle_mocked_task(begins):
        mocker = Mocker()
        ret = mocker.mock()
        ret.overlaps(ANY)
        mocker.result(False)
        mocker.count(0, None)
        ret.begins
        mocker.result(begins)
        mocker.count(0, None)
        mocker.replay()
        return ret
    unpickle_mocked_task.__safe_for_unpickling__ = True
    
    class test_schedules_and_file(MockerTestCase):
        def setUp(self):
            mocker = self.mocker
    
            A = mocker.mock()
            A.overlaps(ANY)
            mocker.result(False)
            mocker.count(0, None)
            A.begins
            mocker.result(5)
            mocker.count(0, None)
            A.__reduce_ex__(ANY)
            mocker.result((unpickle_mocked_task, (5,)))
            mocker.count(0, None)
    
            B = mocker.mock()
            B.overlaps(ANY)
            mocker.result(False)
            mocker.count(0, None)
            B.begins
            mocker.result(3)
            mocker.count(0, None)
            B.__reduce_ex__(ANY)
            mocker.result((unpickle_mocked_task, (3,)))
            mocker.count(0, None)
    
            C = mocker.mock()
            C.overlaps(ANY)
            mocker.result(False)
            mocker.count(0, None)
            C.begins
            mocker.result(7)
            mocker.count(0, None)
            C.__reduce_ex__(ANY)
            mocker.result((unpickle_mocked_task, (7,)))
            mocker.count(0, None)
    
            self.A = A
            self.B = B
            self.C = C
    
            mocker.replay()
    
        def tearDown(self):
            try:
                unlink('test_schedules_and_file.sqlite')
            except OSError:
                pass
    
        def test_save_and_restore(self):
            sched1 = schedules()
    
            sched1.add(self.A)
            sched1.add(self.B)
            sched1.add(self.C)
    
            store1 = file('test_schedules_and_file.sqlite')
            sched1.store(store1)
    
            del sched1
            del store1
    
            store2 = file('test_schedules_and_file.sqlite')
            sched2 = schedules.load(store2)
    
            self.assertEqual(set([x.begins for x in sched2.tasks]),
                             set([3, 5, 7]))
    
  10. 我们现在已经构建到了最外层圆圈,这意味着现在是时候编写涉及整个系统的测试,没有任何模拟对象。

    from planner.data import schedules, statuses, activities, schedule_error
    from planner.persistence import file
    from unittest import TestCase
    from datetime import datetime, timedelta
    from os import unlink
    
    class test_system(TestCase):
        def setUp(self):
            self.A = statuses('A',
                              datetime.now(),
                              datetime.now() + timedelta(minutes = 7))
            self.B = statuses('B',
                             datetime.now() - timedelta(hours = 1),
                             datetime.now() + timedelta(hours = 1))
            self.C = statuses('C',
                             datetime.now() + timedelta(minutes = 10),
                             datetime.now() + timedelta(hours = 1))
    
            self.D = activities('D',
                              datetime.now(),
                              datetime.now() + timedelta(minutes = 7))
    
            self.E = activities('E',
                              datetime.now() + timedelta(minutes=30),
                              datetime.now() + timedelta(hours = 1))
    
            self.F = activities('F',
                              datetime.now() - timedelta(minutes=20),
                              datetime.now() + timedelta(minutes=40))
    
        def tearDown(self):
            try:
                unlink('test_system.sqlite')
            except OSError:
                pass
    
        def test_usage_pattern(self):
            sched1 = schedules()
    
            sched1.add(self.A)
            sched1.add(self.B)
            sched1.add(self.C)
            sched1.add(self.D)
            sched1.add(self.E)
    
            store1 = file('test_system.sqlite')
            sched1.store(store1)
    
            del store1
    
            store2 = file('test_system.sqlite')
            sched2 = schedules.load(store2)
    
            self.assertEqual(sched1, sched2)
    
            sched2.remove(self.D)
            sched2.remove(self.E)
    
            self.assertNotEqual(sched1, sched2)
    
            sched2.add(self.F)
    
            self.assertTrue(self.F in sched2)
            self.assertFalse(self.F in sched1)
    
            self.assertRaises(schedule_error, sched2.add, self.D)
            self.assertRaises(schedule_error, sched2.add, self.E)
    
            self.assertTrue(self.A in sched1)
            self.assertTrue(self.B in sched1)
            self.assertTrue(self.C in sched1)
            self.assertTrue(self.D in sched1)
            self.assertTrue(self.E in sched1)
            self.assertFalse(self.F in sched1)
    
            self.assertTrue(self.A in sched2)
            self.assertTrue(self.B in sched2)
            self.assertTrue(self.C in sched2)
            self.assertFalse(self.D in sched2)
            self.assertFalse(self.E in sched2)
            self.assertTrue(self.F in sched2)
    
    

刚才发生了什么?

我们刚刚测试了整个代码库,始终注意一次测试一件事。因为我们采取了逐步进行的做法,所以我们总是知道新发现的错误起源于何处,并且我们能够轻松地修复它们。

让我们花点时间检查代码的每个部分。

class statuses_integration_tests(TestCase):
    def setUp(self):
        self.A = statuses('A',
                          datetime(year=2008, month=7, day=15),
                          datetime(year=2009, month=5, day=2))

在我们的setUp方法中,我们在这里创建了一个状态对象。因为这是一个setUp方法——测试固定的一部分——每个测试都将有自己的唯一版本self.A,一个测试中做出的更改对其他任何测试都是不可见的。

    def test_equality(self):
        self.assertEqual(self.A, self.A)
        self.assertNotEqual(self.A, statuses('B',
                          datetime(year=2008, month=7, day=15),
                          datetime(year=2009, month=5, day=2)))
        self.assertNotEqual(self.A, statuses('A',
                          datetime(year=2007, month=7, day=15),
                          datetime(year=2009, month=5, day=2)))
        self.assertNotEqual(self.A, statuses('A',
                          datetime(year=2008, month=7, day=15),
                          datetime(year=2010, month=5, day=2)))

test_equality测试检查一个状态是否与自己比较相等,以及名称、开始时间或结束时间的差异会导致状态比较为不相等。

def test_overlap_begin(self):
        status = statuses('status name',
                          datetime(year=2007, month=8, day=11),
                          datetime(year=2008, month=11, day=27))

        self.assertTrue(status.overlaps(self.A))

    def test_overlap_end(self):
        status = statuses('status name',
                          datetime(year=2008, month=1, day=11),
                          datetime(year=2010, month=4, day=16))

        self.assertTrue(status.overlaps(self.A))

    def test_overlap_inner(self):
        status = statuses('status name',
                          datetime(year=2007, month=10, day=11),
                          datetime(year=2010, month=1, day=27))

        self.assertTrue(status.overlaps(self.A))

    def test_overlap_outer(self):
        status = statuses('status name',
                          datetime(year=2008, month=8, day=12),
                          datetime(year=2008, month=9, day=15))

        self.assertTrue(status.overlaps(self.A))

    def test_overlap_after(self):
        status = statuses('status name',
                          datetime(year=2011, month=2, day=6),
                          datetime(year=2015, month=4, day=27))

        self.assertFalse(status.overlaps(self.A))

这一系列测试检查状态是否能够正确识别它们何时重叠,无论这种重叠发生在开始、结束,还是因为一个状态包含在另一个状态中。

class activities_integration_tests(TestCase):
    def setUp(self):
        self.A = activities('A',
                          datetime(year=2008, month=7, day=15),
                          datetime(year=2009, month=5, day=2))

    def test_repr(self):
        self.assertEqual(repr(self.A), '<A 2008-07-15T00:00:00 2009-05-02T00:00:00>')

    def test_equality(self):
        self.assertEqual(self.A, self.A)
        self.assertNotEqual(self.A, activities('B',
                          datetime(year=2008, month=7, day=15),
                          datetime(year=2009, month=5, day=2)))
        self.assertNotEqual(self.A, activities('A',
                          datetime(year=2007, month=7, day=15),
                          datetime(year=2009, month=5, day=2)))
        self.assertNotEqual(self.A, activities('A',
                          datetime(year=2008, month=7, day=15),
                          datetime(year=2010, month=5, day=2)))

与状态一样,活动是通过在setUp方法中创建一个样本对象并在测试中对其执行操作来测试的。相等性检查与状态中的相同;我们希望确保不同的名称、开始时间或结束时间意味着两个活动不相等。

def test_overlap_begin(self):
        activity = activities('activity name',
                          datetime(year=2007, month=8, day=11),
                          datetime(year=2008, month=11, day=27))

        self.assertTrue(activity.overlaps(self.A))
        self.assertTrue(activity.excludes(self.A))

    def test_overlap_end(self):
        activity = activities('activity name',
                          datetime(year=2008, month=1, day=11),
                          datetime(year=2010, month=4, day=16))

        self.assertTrue(activity.overlaps(self.A))
        self.assertTrue(activity.excludes(self.A))

    def test_overlap_inner(self):
        activity = activities('activity name',
                          datetime(year=2007, month=10, day=11),
                          datetime(year=2010, month=1, day=27))

        self.assertTrue(activity.overlaps(self.A))
        self.assertTrue(activity.excludes(self.A))

    def test_overlap_outer(self):
        activity = activities('activity name',
                          datetime(year=2008, month=8, day=12),
                          datetime(year=2008, month=9, day=15))

        self.assertTrue(activity.overlaps(self.A))
        self.assertTrue(activity.excludes(self.A))

    def test_overlap_after(self):
        activity = activities('activity name',
                          datetime(year=2011, month=2, day=6),
                          datetime(year=2015, month=4, day=27))

        self.assertFalse(activity.overlaps(self.A))

这一系列测试确保活动能够正确识别它们之间何时重叠,无论这种重叠发生在开始、结束还是中间。

class schedules_tests(MockerTestCase):
    def setUp(self):
        mocker = self.mocker

        A = mocker.mock()
        A.__eq__(MATCH(lambda x: x is A))
        mocker.result(True)
        mocker.count(0, None)
        A.__eq__(MATCH(lambda x: x is not A))
        mocker.result(False)
        mocker.count(0, None)
        A.overlaps(ANY)
        mocker.result(False)
        mocker.count(0, None)
        A.begins
        mocker.result(5)
        mocker.count(0, None)

        B = mocker.mock()
        A.__eq__(MATCH(lambda x: x is B))
        mocker.result(True)
        mocker.count(0, None)
        B.__eq__(MATCH(lambda x: x is not B))
        mocker.result(False)
        mocker.count(0, None)
        B.overlaps(ANY)
        mocker.result(False)
        mocker.count(0, None)
        B.begins
        mocker.result(3)
        mocker.count(0, None)

        C = mocker.mock()
        C.__eq__(MATCH(lambda x: x is C))
        mocker.result(True)
        mocker.count(0, None)
        C.__eq__(MATCH(lambda x: x is not C))
        mocker.result(False)
        mocker.count(0, None)
        C.overlaps(ANY)
        mocker.result(False)
        mocker.count(0, None)
        C.begins
        mocker.result(7)
        mocker.count(0, None)

        self.A = A
        self.B = B
        self.C = C

        mocker.replay()

我们将测试schedules如何与自身交互,但尚未测试它与activitiesstatuses的交互。因此,我们需要一些模拟对象来代表这些事物。在这里的测试固定装置中,我们创建了三个模拟对象,正是为了这个目的。

    def test_equality(self):
        sched1 = schedules()
        sched2 = schedules()

        self.assertEqual(sched1, sched2)

        sched1.add(self.A)
        sched1.add(self.B)

        sched2.add(self.A)
        sched2.add(self.B)
        sched2.add(self.C)

        self.assertNotEqual(sched1, sched2)

        sched1.add(self.C)

        self.assertEqual(sched1, sched2)

schedules与自身唯一的交互是相等性比较,因此在这里我们已经测试了两个实际计划之间的比较是否按预期工作。

class test_schedules_and_statuses(TestCase):
    def setUp(self):
        self.A = statuses('A',
                          datetime.now(),
                          datetime.now() + timedelta(minutes = 7))
        self.B = statuses('B',
                          datetime.now() - timedelta(hours = 1),
                          datetime.now() + timedelta(hours = 1))
        self.C = statuses('C',
                          datetime.now() + timedelta(minutes = 10),
                          datetime.now() + timedelta(hours = 1))

在之前我们使用模拟对象来表示状态的情况下,现在我们可以使用真实的事物。由于我们正在测试 schedules statuses之间的交互,我们需要使用真实的事物。

    def test_usage_pattern(self):
        sched = schedules()

        sched.add(self.A)
        sched.add(self.C)

        self.assertTrue(self.A in sched)
        self.assertTrue(self.C in sched)
        self.assertFalse(self.B in sched)

        sched.add(self.B)

        self.assertTrue(self.B in sched)
        self.assertEqual(sched, sched)
        sched.remove(self.A)

        self.assertFalse(self.A in sched)
        self.assertTrue(self.B in sched)
        self.assertTrue(self.C in sched)

        sched.remove(self.B)
        sched.remove(self.C)

        self.assertFalse(self.B in sched)
        self.assertFalse(self.C in sched)

这个测试在一个测试中运行了 schedules statuses之间整个预期的使用模式。当我们进行单元测试时,这种做法并不好,因为它自然涉及到多个单元。然而,我们现在正在进行集成测试,所有涉及的单元都已经单独进行了测试。我们实际上希望它们相互交互以确保它们可以正常工作,这是实现这一目标的好方法。

class test_schedules_and_activities(TestCase):
    def setUp(self):
        self.A = activities('A',
                          datetime.now(),
                          datetime.now() + timedelta(minutes = 7))
        self.B = activities('B',
                          datetime.now() - timedelta(hours = 1),
                          datetime.now() + timedelta(hours = 1))
        self.C = activities('C',
                          datetime.now() + timedelta(minutes = 10),
                          datetime.now() + timedelta(hours = 1))

    def test_usage_pattern(self):
        sched = schedules()

        sched.add(self.A)
        sched.add(self.C)

        self.assertTrue(self.A in sched)
        self.assertTrue(self.C in sched)
        self.assertFalse(self.B in sched)

        self.assertRaises(schedule_error, sched.add, self.B)

        self.assertFalse(self.B in sched)

        self.assertEqual(sched, sched)

        sched.remove(self.A)

        self.assertFalse(self.A in sched)
        self.assertFalse(self.B in sched)
        self.assertTrue(self.C in sched)

        sched.remove(self.C)

        self.assertFalse(self.B in sched)
        self.assertFalse(self.C in sched)

这些测试与 schedules statuses一起的测试非常相似。差异是由于活动可以排除彼此参与计划,因此当我们尝试将重叠的活动添加到计划中时,应该抛出一个异常,然后不应将其添加到计划中。

class test_schedules_activities_and_statuses(TestCase):
    def setUp(self):
        self.A = statuses('A',
                          datetime.now(),
                          datetime.now() + timedelta(minutes = 7))
        self.B = statuses('B',
                          datetime.now() - timedelta(hours = 1),
                          datetime.now() + timedelta(hours = 1))
        self.C = statuses('C',
                          datetime.now() + timedelta(minutes = 10),
                          datetime.now() + timedelta(hours = 1))

        self.D = activities('D',
                            datetime.now(),
                            datetime.now() + timedelta(minutes = 7))

        self.E = activities('E',
                            datetime.now() + timedelta(minutes=30),
                            datetime.now() + timedelta(hours=1))

        self.F = activities('F',
                            datetime.now() - timedelta(minutes=20),
                            datetime.now() + timedelta(minutes=40))

我们在这里根本不使用任何模拟。这些测试使用 schedules activities statuses,没有任何限制它们的交互。我们的测试固定装置只是创建了一堆它们,所以我们不需要在每个测试中重复这段代码。

    def test_usage_pattern(self):
        sched = schedules()

        sched.add(self.A)
        sched.add(self.B)
        sched.add(self.C)

        sched.add(self.D)

        self.assertTrue(self.A in sched)
        self.assertTrue(self.B in sched)
        self.assertTrue(self.C in sched)
        self.assertTrue(self.D in sched)

        self.assertRaises(schedule_error, sched.add, self.F)
        self.assertFalse(self.F in sched)

        sched.add(self.E)
        sched.remove(self.D)

        self.assertTrue(self.E in sched)
        self.assertFalse(self.D in sched)

        self.assertRaises(schedule_error, sched.add, self.F)
        self.assertFalse(self.F in sched)

        sched.remove(self.E)

        self.assertFalse(self.E in sched)

        sched.add(self.F)

        self.assertTrue(self.F in sched)

在这里,我们再次有一个针对完整使用模式的单个测试。我们故意不对测试组件之间的交互进行限制;相反,我们将它们组合在一起并确保它们可以正常工作。

class test_file(TestCase):
    def setUp(self):
        storage = file('file_test.sqlite')

        storage.store_object('tag1', 'A')
        storage.store_object('tag2', 'B')
        storage.store_object('tag1', 'C')
        storage.store_object('tag1', 'D')
        storage.store_object('tag3', 'E')
        storage.store_object('tag3', 'F')

    def tearDown(self):
        unlink('file_test.sqlite')

我们的测试固定装置在每次测试运行之前创建一个持久化数据库,包含几个对象,并在每次测试之后删除该数据库。通常情况下,这意味着我们知道每个测试的环境看起来是什么样子,它们不会相互影响。

    def test_other_instance(self):
        storage = file('file_test.sqlite')

        self.assertEqual(set(storage.load_objects('tag1')),
                         set(['A', 'C', 'D']))

        self.assertEqual(set(storage.load_objects('tag2')),
                         set(['B']))

        self.assertEqual(set(storage.load_objects('tag3')),
                         set(['E', 'F']))

在这个测试中,我们创建一个新的持久化文件对象,并告诉它从setUp方法中创建的数据库加载数据。然后我们确保加载的数据符合我们的预期。

当我们运行这个测试时,出现了一个之前没有出现的错误。数据库的更改没有被提交到文件中,因此它们在存储它们的交易之外是不可见的。没有在单独的交易中测试持久化代码是一个疏忽,但这正是我们进行集成测试来捕捉的错误。

我们可以通过修改persistence.pyfile类的store_object方法来解决这个问题:

    def store_object(self, tag, object):
        self.connection.execute('insert into objects values (?, ?)',
                               (tag, sqlite3.Binary(dumps(object))))
 self.connection.commit()
def unpickle_mocked_task(begins):
    mocker = Mocker()
    ret = mocker.mock()
    ret.overlaps(ANY)
    mocker.result(False)
    mocker.count(0, None)
    ret.begins
    mocker.result(begins)
    mocker.count(0, None)
    mocker.replay()
    return ret
unpickle_mocked_task.__safe_for_unpickling__ = True

unpickle_mocked_task函数是必要的,因为 mocks 处理得不是很好的一件事是“pickle”和“unpickle”。我们因为这样在file的测试中使用了元组,但我们需要对这个测试进行 mocks,所以我们必须额外麻烦地告诉 Pickle 如何处理它们。

class test_schedules_and_file(MockerTestCase):
    def setUp(self):
        mocker = self.mocker

        A = mocker.mock()
        A.overlaps(ANY)
        mocker.result(False)
        mocker.count(0, None)
        A.begins
        mocker.result(5)
        mocker.count(0, None)
        A.__reduce_ex__(ANY)
        mocker.result((unpickle_mocked_task, (5,)))
        mocker.count(0, None)

        B = mocker.mock()
        B.overlaps(ANY)
        mocker.result(False)
        mocker.count(0, None)
        B.begins
        mocker.result(3)
        mocker.count(0, None)
        B.__reduce_ex__(ANY)
        mocker.result((unpickle_mocked_task, (3,)))
        mocker.count(0, None)

        C = mocker.mock()
        C.overlaps(ANY)
        mocker.result(False)
        mocker.count(0, None)
        C.begins
        mocker.result(7)
        mocker.count(0, None)
        C.__reduce_ex__(ANY)
        mocker.result((unpickle_mocked_task, (7,)))
        mocker.count(0, None)

        self.A = A
        self.B = B
        self.C = C

        mocker.replay()

    def tearDown(self):
        try:
            unlink('test_schedules_and_file.sqlite')
        except OSError:
            pass

到现在为止,这应该是一种相当熟悉的测试环境。新的东西是tearDown方法将删除一个数据库文件(如果存在的话),但不会对它不存在而抱怨。数据库预计将在测试本身中创建,我们不希望它留在外面,但如果它不存在,这并不是测试环境的错误。

    def test_save_and_restore(self):
        sched1 = schedules()

        sched1.add(self.A)
        sched1.add(self.B)
        sched1.add(self.C)

        store1 = file('test_schedules_and_file.sqlite')
        sched1.store(store1)

        del sched1
        del store1

        store2 = file('test_schedules_and_file.sqlite')
        sched2 = schedules.load(store2)

        self.assertEqual(set([x.begins for x in sched2.tasks]),
                         set([3, 5, 7]))

我们正在测试调度和持久化文件之间的交互,这意味着我们已经创建并填充了一个调度,创建了一个持久化文件,存储了调度,然后使用相同的数据库文件创建了一个新的持久化文件对象,并从中加载了一个新的调度。如果加载的调度符合我们的预期,那么一切正常。

本章中的许多测试代码可能看起来很冗余。这在某种程度上是正确的。有些事情在不同的测试中被反复检查。为什么还要这样做呢?

重复性的主要原因在于每个测试都应该独立存在。我们不应该关心它们的运行顺序,或者是否有其他测试存在。每个测试都是自包含的,所以如果它失败了,我们就知道需要修复什么。因为每个测试都是自包含的,一些基础的东西最终会被多次测试。在这个简单的项目中,这种重复性比通常情况下更为明显。

无论这种重复性是明显的还是微妙的,都不是问题。所谓的 DRY(不要重复自己)原则并不特别适用于测试。多次测试某事物并没有多少缺点。这并不是说复制粘贴测试是个好主意,因为显然不是。不要对测试之间的相似性感到惊讶或恐慌,但不要以此为借口。

突击测验 - 编写集成测试

  1. 你应该先编写哪些集成测试?

  2. 当你有一大块集成代码,但下一个需要引入的部分根本没有任何集成测试时,会发生什么?

  3. 写测试来检查代码块与自身集成的目的是什么?

  4. 系统测试是什么,系统测试如何与集成测试相关?

尝试一下英雄 - 集成你自己的程序

之前,你为你的一个程序编写了一个集成图。现在是时候跟进并针对该代码编写集成测试,由图来指导。

摘要

在本章中,我们学习了从单元测试的基础构建到覆盖整个系统的测试集的过程。

具体来说,我们涵盖了:

  • 如何绘制集成图

  • 如何解释集成图以决定测试构建的顺序

  • 如何编写集成测试

既然我们已经了解了集成测试,我们现在准备介绍一些其他有用的测试工具和策略——这是下一章的主题。

第十章。其他测试工具和技术

我们已经涵盖了 Python 测试的核心元素,但还有一些外围方法和工具可以使你的生活更轻松。在本章中,我们将简要介绍其中的一些。

在本章中,我们将:

  • 讨论代码覆盖率,并了解coverage.py

  • 讨论持续集成,并了解 buildbot

  • 学习如何将自动化测试集成到流行的版本控制系统

所以,让我们继续吧!

代码覆盖率

测试告诉你当你测试的代码没有按预期工作时的信息,但它们不会告诉你关于你未测试的代码的任何信息。它们甚至不会告诉你你未测试的代码没有被测试。

代码覆盖率是一种技术,可以用来解决这一不足。代码覆盖率工具在测试运行时进行监控,并跟踪哪些代码行被(和没有被)执行。测试运行完毕后,该工具将给出一份报告,描述你的测试如何覆盖整个代码库。

如你可能已经想到的,希望覆盖率接近 100%。但请注意,不要过于关注覆盖率数字,它可能会有些误导。即使你的测试执行了程序中的每一行代码,它们也可能没有测试到需要测试的所有内容。这意味着你不能将 100%的覆盖率作为测试完整的确定性证据。另一方面,有时某些代码确实不需要被测试覆盖——例如一些调试支持代码——因此低于 100%的覆盖率可能是完全可接受的。

代码覆盖率是一个工具,可以帮助你了解你的测试正在做什么,以及它们可能忽略什么。它并不是良好测试套件的定义。

coverage.py

我们将使用一个名为coverage.py的模块,这并不令人惊讶,它是一个 Python 的代码覆盖率工具。

由于coverage.py不是 Python 内置的,我们需要下载并安装它。你可以从 Python 包索引pypi.python.org/pypi/coverage下载最新版本。与之前一样,Python 2.6 或更高版本的用户可以通过解压存档、切换到目录并键入以下命令来安装该包:

$ python setup.py install --user 

注意

Python 旧版本的用户需要具有对系统级site-packages目录的写入权限,这是 Python 安装的一部分。任何拥有此类权限的人都可以通过键入以下命令来安装 coverage:

$ python setup.py install

在撰写本文时,Windows 用户还可以从 Python 包索引下载 Windows 安装程序文件,并运行它来安装coverage.py

我们将在这里介绍使用coverage.py的步骤,但如果你需要更多信息,可以在coverage.py的主页nedbatchelder.com/code/coverage/上找到。

是时候使用 coverage.py 了

我们将创建一个包含测试的toy代码模块,然后应用coverage.py来找出测试实际上使用了多少代码。

  1. 将以下测试代码放入test_toy.py。这些测试有几个问题,我们稍后会讨论,但它们应该能运行。

    from unittest import TestCase
    import toy
    
    class test_global_function(TestCase):
        def test_positive(self):
            self.assertEqual(toy.global_function(3), 4)
    
        def test_negative(self):
            self.assertEqual(toy.global_function(-3), -2)
    
        def test_large(self):
            self.assertEqual(toy.global_function(2**13), 2**13 + 1)
    
    class test_example_class(TestCase):
        def test_timestwo(self):
            example = toy.example_class(5)
            self.assertEqual(example.timestwo(), 10)
    
        def test_repr(self):
            example = toy.example_class(7)
            self.assertEqual(repr(example), '<example param="7">')
    
  2. 将以下代码放入toy.py。注意底部的if __name__ == '__main__'子句。我们已经有一段时间没有处理这样的子句了,所以我将提醒你,如果我们用python toy.py运行模块,该块内的代码将运行 doctest。

    def global_function(x):
        r"""
        >>> global_function(5)
        6
        """
        return x + 1
    
    class example_class:
        def __init__(self, param):
            self.param = param
    
        def timestwo(self):
            return self.param * 2
    
        def __repr__(self):
            return '<example param="%s">' % self.param
    if __name__ == '__main__':
        import doctest
        doctest.testmod()
    
  3. 继续运行 Nose。它应该能找到它们,运行它们,并报告一切正常。问题是,有些代码从未被测试过。

  4. 让我们再次运行它,但这次我们将告诉 Nose 在运行测试时使用coverage.py来测量覆盖率。

    $ nosetests --with-coverage --cover-erase 
    
    

    行动时间 - 使用 coverage.py

发生了什么?

在第一步中,我们有两个包含一些非常基础测试的TestCase类。这些测试在现实世界中的情况下可能没什么用,但我们只需要它们来展示代码覆盖率工具是如何工作的。

在第二步中,我们有满足第一步测试的代码。就像测试本身一样,这段代码可能没什么用,但它起到了示范的作用。

在第四步中,我们在运行 Nose 时传递了--with-coverage--cover-erase作为命令行参数。它们做了什么?好吧,--with-coverage相当直接:它告诉 Nose 在测试执行时查找coverage.py并使用它。这正是我们想要的。第二个参数--cover-erase告诉 Nose 忘记在之前的运行中获取的任何覆盖率信息。默认情况下,覆盖率信息是跨所有coverage.py的使用汇总的。这允许你使用不同的测试框架或机制运行一系列测试,然后检查累积覆盖率。然而,你仍然希望在开始这个过程时清除之前测试运行的数据,--cover-erase命令行就是告诉 Nose 告诉coverage.py你正在重新开始的方法。

覆盖率报告告诉我们,在玩具模块中,9/12(换句话说,75%)的可执行语句在我们的测试中得到了执行,而缺失的行是第 16 行和第 19 到第 20 行。回顾我们的代码,我们看到第 16 行是__repr__方法。我们真的应该测试那个,所以覆盖率检查揭示了我们的测试中的一个漏洞,我们应该修复它。第 19 和第 20 行只是运行 doctest 的代码。它们不是我们在正常情况下应该使用的东西,所以我们可以忽略那个覆盖率漏洞。

在大多数情况下,代码覆盖率无法检测测试本身的问题。在上面的测试代码中,对timestwo方法的测试违反了单元隔离,调用了example_class的两个不同方法。由于其中一个方法是构造函数,这可能是可以接受的,但覆盖率检查器甚至无法看到可能存在问题的迹象。它只看到了更多被覆盖的代码行。这不是问题——这正是覆盖率检查器应该工作的方式——但这是需要记住的。覆盖率是有用的,但高覆盖率并不等于好的测试。

快速问答——代码覆盖率

  1. 高覆盖率百分比意味着什么?

  2. 如果您的老板要求您提供一个测试质量的量化指标,您会使用覆盖率百分比吗?

  3. 覆盖率报告中最重要的信息是什么?

勇敢的尝试者——检查前面章节的覆盖率

回过头来查看前面章节的代码,并使用代码覆盖率检查应该被测试但未被测试的事情。也尝试在您自己的测试代码上试试。

版本控制钩子

大多数版本控制系统都有运行您编写的程序的能力,以响应各种事件,作为定制版本控制系统行为的一种方式。这些程序通常被称为钩子。

注意

版本控制系统是跟踪源代码树变更的程序,即使这些变更是由不同的人进行的。从某种意义上说,它们为整个项目提供了一个通用的撤销历史和变更日志,一直追溯到您开始使用版本控制系统的那一刻。它们还使得将不同人完成的工作合并成一个单一、统一实体变得容易得多,并且可以跟踪同一项目的不同版本。

通过安装正确的钩子程序,您可以做各种事情,但我们将只关注一种用途。我们可以让版本控制系统在将代码的新版本提交到版本控制存储库时自动运行我们的测试。

这是一个相当巧妙的技巧,因为它使得测试破坏性错误在不知不觉中进入存储库变得困难。虽然它与代码覆盖率类似,但如果它变成政策问题而不是仅仅作为一个使您生活更轻松的工具,那么就存在潜在的问题。

在大多数系统中,你可以编写钩子,使得无法提交破坏测试的代码。这听起来可能是个好主意,但实际上并不是。其中一个原因是,版本控制系统的主要目的之一是开发者之间的沟通,而干扰这一点在长期来看往往是不 productive 的。另一个原因是,它阻止任何人提交问题的部分解决方案,这意味着事情往往会以大块的形式被提交到仓库中。大提交是一个问题,因为它们使得跟踪发生了什么变得困难,这增加了混乱。有更好的方法来确保你总是有一个工作代码库保存在某个地方,比如版本控制分支。

Bazaar

Bazaar 是一个分布式版本控制系统,这意味着它能够在没有中央服务器或源代码主副本的情况下运行。Bazaar 分布式特性的一个后果是,每个用户都有自己的钩子集,可以在不涉及任何其他人的情况下添加、修改或删除。Bazaar 可在互联网上找到,网址为 bazaar-vcs.org/

如果你还没有安装 Bazaar,并且不打算使用它,你可以跳过这一部分。

行动时间 - 安装 Nose 作为 Bazaar 的后提交钩子

  1. Bazaar 钩子放在你的 plugins 目录中。在类 Unix 系统上,这是 ~/.bazaar/plugins/,而在 Windows 上是 C:\Documents and Settings\<username>\Application Data\Bazaar\<version>\plugins\。在任一情况下,你可能需要创建 plugins 子目录,如果它尚未存在的话。

  2. 将以下代码放入 plugins 目录下的一个名为 run_nose.py 的文件中。Bazaar 钩子是用 Python 编写的:

    from bzrlib import branch
    from os.path import join, sep
    from os import chdir
    from subprocess import call
    
    def run_nose(local, master, old_num, old_id, new_num, new_id):
        try:
            base = local.base
        except AttributeError:
            base = master.base
    
        if not base.startswith('file://'):
            return
        try:
            chdir(join(sep, *base[7:].split('/')))
        except OSError:
            return
    
        call(['nosetests'])
    
    branch.Branch.hooks.install_named_hook('post_commit',
                                           run_nose,
                                           'Runs Nose after eachcommit')
    
  3. 在你的工作文件中创建一个新的目录,并将以下代码放入该目录下,文件名为 test_simple.py。这些简单的(而且有些愚蠢的)测试只是为了给 Nose 些事情做,这样我们就可以看到钩子是否在正常工作。

    from unittest import TestCase
    
    class test_simple(TestCase):
        def test_one(self):
            self.assertNotEqual("Testing", "Hooks")
    
        def test_two(self):
            self.assertEqual("Same", "Same")
    
  4. 仍然在 test_simple.py 所在的目录下,运行以下命令以创建一个新的仓库并将测试提交到其中。你看到的输出可能细节上有所不同,但总体上应该是相当相似的。

    $ bzr init
    $ bzr add
    $ bzr commit
    
    

    行动时间 - 安装 Nose 作为 Bazaar 的后提交钩子

  5. 注意,在提交通知之后会有一个 Nose 测试报告。从现在开始,每次你向 Bazaar 仓库提交时,Nose 都会搜索并运行该仓库内能找到的任何测试。

刚才发生了什么?

Bazaar 的钩子是用 Python 编写的,因此我们编写了一个名为 run_nose 的函数作为钩子。我们的 run_nose 函数会检查我们正在工作的仓库是否是本地的,然后它会切换到仓库目录并运行 nose。我们通过调用 branch.Branch.hooks.install_named_hookrun_nose 注册为一个钩子。

Mercurial

类似于 Bazaar,Mercurial 也是一个分布式版本控制系统,其钩子由每个用户单独管理。然而,Mercurial 的钩子本身采取了相当不同的形式。你可以在网上找到 Mercurial,网址为 www.selenic.com/mercurial/.

如果你没有安装 Mercurial,并且不打算使用它,你可以跳过这一部分。

Mercurial 钩子可以放在几个不同的地方。最有用的是你的个人配置文件和你的存储库配置文件。

你的个人配置文件在类 Unix 系统上是 ~/.hgrc,在基于 Windows 的系统上是 %USERPROFILE%\Mercurial.ini(通常意味着 c:\Documents and Settings\<username>\Mercurial.ini)。

你的存储库配置文件存储在存储库的子目录中,具体为所有系统上的 .hg/hgrc

行动时间 - 将 Nose 作为 Mercurial post-commit 钩子安装

  1. 我们将使用存储库配置文件来存储钩子,这意味着我们首先要做的是拥有一个可以工作的存储库。在方便的地方创建一个新的目录,并在其中执行以下命令:

    $ hg init
    
    
  2. 该命令的一个副作用是创建了一个 .hg 子目录。切换到该目录,然后创建一个名为 hgrc 的文本文件,包含以下文本:

    [hooks]
    commit = nosetests
    
  3. 回到存储库目录(即 .hg 目录的父目录),我们需要为 Nose 运行一些测试。创建一个名为 test_simple.py 的文件,包含以下(诚然有些愚蠢)的测试:

    from unittest import TestCase
    
    class test_simple(TestCase):
        def test_one(self):
            self.assertNotEqual("Testing", "Hooks")
    
        def test_two(self):
            self.assertEqual("Same", "Same")
    
  4. 运行以下命令以添加测试文件并将其提交到存储库:

    
    $ hg add
    $ hg commit
    
    

    行动时间 – 将 Nose 作为 Mercurial post-commit 钩子安装

  5. 注意到提交触发了测试的运行。由于我们将钩子放入了存储库配置文件,它只会在提交到这个存储库时生效。如果我们将其放入你的个人配置文件,它将在你提交到 任何 存储库时被调用。

发生了什么?

Mercurial 的钩子是命令,就像你会在你的操作系统命令壳(在 Windows 上也称为 DOS 提示符)中输入的命令一样。我们只需编辑 Mercurial 的配置文件,并告诉它要运行哪个命令。由于我们希望它在提交时运行我们的 Nose 测试套件,我们将提交钩子设置为 nosetests

Git

Git 是一个分布式版本控制系统。类似于 Bazaar 和 Mercurial,它允许每个用户控制自己的钩子,而不涉及其他开发者或服务器管理员。

注意

Git 钩子存储在存储库的 .git/hooks/ 子目录中,每个钩子都有自己的文件。

如果你没有安装 Git,并且不打算使用它,你可以跳过这一部分。

行动时间 - 将 Nose 作为 Git post-commit 钩子安装

  1. 钩子存储在 Git 仓库的子目录中,因此我们首先需要做的事情是初始化一个仓库。为 Git 仓库创建一个新的目录,并在其中执行以下命令:

    $ git init
    
    
  2. Git 钩子是可执行程序,因此可以用任何语言编写。要运行 Nose,使用 shell 脚本(在类 Unix 系统中)或批处理文件(在 Windows 中)作为钩子是有意义的。如果你使用的是类 Unix 系统,将以下两行放入.git/hooks/子目录中的名为post-commit的文件中,然后使用chmod +x post-commit命令使其可执行。

    #!/bin/sh
    nosetests
    

    如果你使用的是 Windows 系统,将以下行放入.git\hooks\子目录中名为post-commit.bat的文件中。

    @echo off
    nosetests
    
  3. 我们需要在仓库目录中放置一些测试代码(即.git目录的父目录),这样 Nose 才有事情可做。将以下(无用的)代码放入一个名为test_simple.py的文件中:

    from unittest import TestCase
    
    class test_simple(TestCase):
        def test_one(self):
            self.assertNotEqual("Testing", "Hooks")
    
        def test_two(self):
            self.assertEqual("Same", "Same")
    
  4. 运行以下命令将测试文件添加并提交到仓库中:

    $ git add test_simple.py
    $ git commit -a
    
    

    行动时间 – 将 Nose 作为 Git 后提交钩子安装

  5. 注意,这次提交触发了 Nose 的执行并打印出了测试结果。

    因为每个仓库都有自己的钩子,只有那些特别配置为运行 Nose 的仓库才会这样做。

刚才发生了什么?

Git 通过查找具有特定名称的程序来查找其钩子,因此我们可以用任何编程语言编写我们的钩子,只要我们能给程序正确的名称。然而,我们只想运行nosetests命令,因此我们可以使用简单的 shell 脚本或批处理文件。这个简单的程序所做的只是调用nosetests程序,然后终止。

Darcs

Darcs 是一个分布式版本控制系统。每个用户都控制着自己的钩子集。

如果你没有安装 Darcs,并且你也不打算使用它,你可以跳过这一部分。

行动时间 – 将 Nose 作为 Darcs 后记录钩子安装

  1. 每个本地仓库都有自己的钩子集,因此我们首先需要做的事情是创建一个仓库。创建一个工作目录,并在其中执行以下命令:

    $ darcs initialize
    
    
  2. 我们需要在仓库目录中放置一些测试代码,这样 Nose 才有事情可做。将以下(无用的)代码放入一个名为test_simple.py的文件中。

    from unittest import TestCase
    
    class test_simple(TestCase):
        def test_one(self):
            self.assertNotEqual("Testing", "Hooks")
    
        def test_two(self):
            self.assertEqual("Same", "Same")
    
  3. 运行以下命令将测试文件添加到仓库中:

    $ darcs add test_simple.py
    
    
  4. Darcs 钩子使用命令行选项进行标识。在这种情况下,我们想在告诉 Darcs 记录更改后运行nosetests,所以我们使用以下命令:

    
    $ darcs record --posthook=nosetests
    
    

    行动时间 – 将 Nose 作为 Darcs 后记录钩子安装

  5. 注意,Darcs 在完成记录更改后运行了我们的测试套件,并将结果报告给了我们。

  6. 虽然如此,但 Darcs 并没有记住我们想要 nosetests 成为记录后的钩子。就它而言,那是一次性的交易。幸运的是,我们可以告诉它不同。在 _darcs/prefs/ 子目录中创建一个名为 defaults 的文件,并将以下文本放入其中:

    record posthook nosetests
    
  7. 现在如果我们更改代码并再次记录,nosetests 应该会自动运行,而无需我们特别请求。请对 test_simple.py 文件进行以下更改:

    from unittest import TestCase
    
    class test_simple(TestCase):
        def test_one(self):
            self.assertNotEqual("Testing", "Hooks")
    
        def test_two(self):
            self.assertEqual("Same", "Same!")
    
  8. 运行以下命令以记录更改并运行测试:

    darcs record
    
    

    执行动作时间 - 安装 Nose 作为 Darcs 后记录钩子

  9. 如果你想跳过提交的测试,可以在记录更改时传递 --no-posthook 命令行选项。

刚才发生了什么?

Darcs 钩子指定为命令行选项,因此当我们发出 record 命令时,我们需要指定一个作为钩子运行的程序。由于我们不希望在每次记录更改时都手动执行,我们利用 Darcs 在其配置文件中接受额外命令行选项的能力。这使得将运行 nosetests 作为钩子变为默认行为。

Subversion

与我们讨论过的其他版本控制系统不同,Subversion 是一个集中式的系统。有一个单独的服务器负责跟踪每个人的更改,同时也处理运行钩子。这意味着有一个适用于所有人的单一套钩子,可能由系统管理员控制。

注意

Subversion 钩子存储在服务器仓库的 hooks/ 子目录中的文件中。

如果你没有 Subversion 并且不打算使用它,可以跳过这一部分。

执行动作时间 - 安装 Nose 作为 Subversion 后提交钩子

由于 Subversion 在集中式客户端-服务器架构上运行,因此我们需要为这个示例设置客户端和服务器。它们可以位于同一台计算机上,但它们需要位于不同的目录中。

  1. 首先,我们需要一个服务器。你可以通过创建一个名为 svnrepo 的新目录并执行以下命令来创建一个:

    $ svnadmin create svnrepo/
    
    
  2. 现在我们需要配置服务器以接受我们的提交。为此,我们打开名为 conf/passwd 的文件,并在底部添加以下行:

    testuser = testpass
    
  3. 然后,我们需要编辑 conf/svnserve.conf 文件,并将读取 # password-db = passwd 的行更改为 password-db = passwd

  4. 在我们能够与之交互之前,Subversion 服务器需要运行。这是通过确保我们位于 svnrepo 目录中,然后运行以下命令来完成的:

    svnserve -d -r ..
    
    
  5. 接下来,我们需要将一些测试代码导入到 Subversion 仓库中。创建一个目录,并在其中创建一个名为 test_simple.py 的文件,并将以下(简单而愚蠢)的代码放入其中:

    from unittest import TestCase
    
    class test_simple(TestCase):
        def test_one(self):
            self.assertNotEqual("Testing", "Hooks")
    
        def test_two(self):
            self.assertEqual("Same", "Same")
    

    你可以通过执行以下命令来执行导入:

    $ svn import --username=testuser --password=testpass svn://localhost/svnrepo/
    
    

    该命令可能会打印出一个巨大、令人恐惧的消息,关于记住密码。尽管有警告,但只需说“是”。

  6. 现在我们已经导入代码,我们需要检出它的一个副本来工作。我们可以使用以下命令来完成:

    $ svn checkout --username=testuser --password=testpass svn://localhost/svnrepo/ svn
    
    

    小贴士

    从现在起,在这个示例中,我们将假设 Subversion 服务器正在 Unix-like 环境中运行(客户端可能运行在 Windows 上,我们不在乎)。这样做的原因是,在那些没有 Unix 风格的 shell 脚本语言的系统上,post-commit 钩子的细节有显著不同,尽管概念保持不变。

  7. 以下代码将放入 subversion 服务器仓库中的 hooks/post-commit 文件。(svn updatesvn checkout 行已被包装以适应页面。在实际使用中,这种包装不应存在。)

    #!/bin/sh
    REPO="$1"
    
    if /usr/bin/test -e "$REPO/working"; then
        /usr/bin/svn update --username=testuser --password=testpass "$REPO/working/";
    else
        /usr/bin/svn checkout --username=testuser --password=testpass svn://localhost/svnrepo/ "$REPO/working/";
    fi
    
    cd "$REPO/working/"
    
    exec /usr/bin/nosetests
    
  8. 使用 chmod +x post-commit 命令使钩子可执行。

  9. 切换到步骤 5 中创建的 svn 目录,并编辑 test_simple.py 使其中一个测试失败。我们这样做是因为如果所有测试都通过,Subversion 不会显示任何信息来表明它们已经运行。只有在它们失败时我们才会得到反馈。

    from unittest import TestCase
    
    class test_simple(TestCase):
        def test_one(self):
            self.assertNotEqual("Testing", "Hooks")
    
        def test_two(self):
            self.assertEqual("Same", "Same!")
    
  10. 现在请使用以下命令提交更改:

    $ svn commit --username=testuser --password=testpass
    
    

    执行时间 – 将 Nose 作为 Subversion 提交后钩子安装

  11. 注意到提交触发了 Nose 的执行,并且如果任何测试失败,Subversion 会显示错误。

因为 Subversion 只有一套中央钩子,所以它们会自动应用于使用仓库的任何人。

刚才发生了什么?

Subversion 钩子在服务器上运行。Subversion 通过查找具有特定名称的程序来定位其钩子,因此我们需要创建一个名为 post-commit 的程序作为 post-commit 钩子。我们可以使用任何编程语言来编写钩子,只要程序有正确的名称即可,但我们选择使用 shell 脚本语言,以简化操作。

快速问答 – 版本控制钩子

  1. 将你的自动化测试钩入版本控制系统有哪些方式可以帮助你?

  2. 你可以用版本控制钩子做哪些事情,但又不应该做?

  3. 分布式版本控制系统中的钩子和集中式版本控制系统中的钩子之间最大的区别是什么?

自动化持续集成

自动化持续集成工具是在使用版本控制钩子将测试运行在提交代码到仓库时之上的一步。而不是运行一次测试套件,自动化持续集成系统会编译你的代码(如果需要的话)并在许多不同的环境中多次运行你的测试。

例如,一个自动化的持续集成系统可能会在 Windows、Linux 和 Mac OS X 的每个系统上运行 Python 2.4、2.5 和 2.6 版本的测试。这不仅让你知道代码中的错误,还能让你知道由外部环境引起的意外问题。知道最后一个补丁在 Windows 上破坏了程序,尽管在你的 Linux 系统上运行得很好,这很令人欣慰。

Buildbot

Buildbot 是一种流行的自动化持续集成工具。使用 Buildbot,您可以创建一个 'build slaves' 的网络,每次您将代码提交到您的仓库时,它都会检查您的代码。这个网络可以相当大,并且可以分布在整个互联网上,因此 Buildbot 即使对于分布在世界各地的众多开发者项目也能正常工作。

Buildbot 的主页位于 buildbot.net/。通过该网站的链接,您可以找到手册并下载工具的最新版本。忽略我们之前讨论过的细节,安装需要您解压存档,然后运行命令 python setup.py buildpython setup.py install --user

Buildbot 以两种模式之一运行,称为 buildmasterbuildslave。buildmaster 管理一组 buildslaves,而 buildslaves 在它们各自的环境中运行测试。

行动时间 – 使用 Buildbot 与 Bazaar

  1. 要设置 buildmaster,创建一个用于其操作的目录,然后运行以下命令:

    $ buildbot create-master <directory>
    
    

    其中 <directory> 是您为 buildbot 工作而创建的目录。

  2. 同样,要设置 buildslave,创建一个用于其操作的目录,然后运行以下命令:

    $ buildbot create-slave <directory> <host:port> <name> <password>
    
    

    其中 <directory> 是您为 buildbot 工作而创建的目录,<host:port> 是 buildmaster 可找到的互联网主机和端口,而 <name><password> 是识别此 buildslave 给 buildmaster 的登录信息。所有这些信息(除了目录外)都由 buildmaster 的操作员确定。

  3. 您应该编辑 <directory>/info/admin<directory>/info/host,分别包含您希望与该 buildslave 关联的电子邮件地址和 buildslave 运行环境的描述。

  4. 在 buildmaster 和 buildslave 上,您都需要启动 buildbot 背景进程。为此,请使用以下命令:

    $ buildbot start <directory>
    
    
  5. 配置 buildmaster 是一个重要的话题(并且我们不会详细讨论)。它已在 Buildbot 的自身文档中完全描述。不过,我们将提供一个简单的配置文件,供参考和快速设置。这个特定的配置文件假设您正在使用 Bazaar,但对于其他版本控制系统来说并没有显著差异。以下内容应放入主 <directory>/master.cfg 文件中:

    # -*- python -*-
    # ex: set syntax=python:
    
    c = BuildmasterConfig = {}
    
    c['projectName'] = "<replace with project name>"
    c['projectURL'] = "<replace with project url>"
    c['buildbotURL'] = "http://<replace with master url>:8010/"
    
    c['status'] = []
    from buildbot.status import html
    c['status'].append(html.WebStatus(http_port=8010,
                                      allowForce=True))
    
    c['slavePortnum'] = 9989
    
    from buildbot.buildslave import BuildSlave
    c['slaves'] = [
        BuildSlave("bot1name", "bot1passwd"),
        ]
    
    from buildbot.changes.pb import PBChangeSource
    c['change_source'] = PBChangeSource()
    
    from buildbot.scheduler import Scheduler
    c['schedulers'] = []
    c['schedulers'].append(Scheduler(name="all", branch=None,
                                     treeStableTimer=2 * 60,
                                     builderNames=["buildbot-full"]))
    
    from buildbot.process import factory
    from buildbot.steps.source import Bzr
    from buildbot.steps.shell import Test
    f1 = factory.BuildFactory()
    f1.addStep(Bzr(repourl="<replace with repository url>"))
    f1.addStep(Test(command = 'nosetests'))
    
    b1 = {'name': "buildbot-full",
          'slavename': "bot1name",
          'builddir': "full",
          'factory': f1,
          }
    c['builders'] = [b1]
    
  6. 为了有效地使用 Buildbot 配置,您还需要安装一个版本控制钩子,以便通知 Buildbot 发生了更改。通常,这可以通过从钩子调用 buildbot sendchange 命令来完成,但与 Bazaar 集成有更优雅的方法:将 buildbot 发行存档中的 contrib/bzr_buildbot.py 文件复制到您的 Bazaar 插件目录中,然后编辑 locations.conf 文件,您应该可以在 plugins 目录旁边找到它。将以下条目添加到 locations.conf

    [<your repository path>]
    buildbot_on = change
    buildbot_server = <internet address of your buildmaster>
    buildbot_port = 9989
    

    你需要为每个你想连接到 buildbot 的仓库添加类似的条目。

  7. 一旦你配置了 buildmaster 和 buildslaves,并将 buildbot 集成到你的版本控制系统,并启动了 buildmaster 和 buildslaves,你就可以开始工作了。

发生了什么?

我们刚刚设置了 Buildbot 来运行我们的测试,每当它注意到我们的源代码两小时没有变化时。

我们通过添加一个运行 nosetests 的构建步骤来告诉它运行测试:

f1.addStep(Test(command = 'nosetests'))

我们通过添加一个构建调度器来告诉它等待源代码两小时不变:

c['schedulers'].append(Scheduler(name="all", branch=None,
                                 treeStableTimer=2 * 60,
                                 builderNames=["buildbot-full"]))

你可以通过在浏览器中导航到你在 master.cfg 文件中配置的 buildbotURL 来查看 Buildbot 状态的报告。其中最有用的报告之一是所谓的“瀑布”视图。如果最近的提交通过了测试,你应该会看到类似这样的内容:

发生了什么?

另一方面,当提交未能通过测试时,你会看到更类似于以下的内容:

发生了什么?

无论哪种方式,你也会看到早期版本的历史记录,以及它们是否通过了测试,以及谁做了更改,何时更改,以及测试命令的输出看起来像什么。

突击测验 – Buildbot

  1. 哪种项目最能从 Buildbot 和其他此类工具中受益?

  2. 在什么情况下使用 Buildbot 比仅仅通过版本控制钩子运行 Nose 更好?

  3. 在什么情况下更糟?

  4. 除了运行测试之外,Buildbot 还可以用于哪些任务?

来试试英雄

这是一个开放式的任务:将你所学的知识应用到实践中。先尝试一个小项目(但让它成为测试驱动的),将测试集成到你的版本控制系统中。一旦你有了一个实现,使用代码覆盖率来帮助你拥有一个全面的测试套件。如果你的项目适用,可以使用 Buildbot。

摘要

在本章中,我们关于代码覆盖率以及将我们的测试集成到我们在编写软件时使用的其他自动化系统中学到了很多。

具体来说,我们涵盖了:

  • 代码覆盖率是什么,以及它能够告诉我们关于测试的什么信息

  • 当我们的版本控制软件检测到源代码有变化时,如何自动运行 Nose

  • 如何设置 Buildbot 自动持续集成系统

现在我们已经了解了代码覆盖率、版本控制钩子和自动化持续集成,你准备好应对更多或更少的事情了。恭喜你!

附录 A. 突击测验答案

第二章

突击测验 – doctest 语法

  1. 测试表达式总是以 >>> 开头。

  2. 行的延续始终以...开始

  3. 预期输出从表达式之后立即开始,直到下一个空白行。

  4. 通过使用规范化空白指令。

  5. doctest 忽略Traceback (most recent last call)之间的所有内容。

  6. 同一文本文件中的所有后续代码都可以看到这个变量。

  7. 我们关心这一点,因为测试应该彼此独立,如果两个测试使用相同的变量,它们可能会相互影响结果。

  8. 我们可以用省略号(...)在预期输出中表示该部分。

第三章

突击测验 – 理解单位

  1. 3 个单元存在:__init__method1method2

  2. method1method2都假设__init__的正确操作,并且method2还假设method1的正确操作。

  3. method2的测试需要使用假的method1

突击测验 – 设计期间的单元测试

  1. 我们现在构建的测试是整个开发过程的基础。我们在这里做出的选择将影响之后的一切;正确地完成这一点很重要。

  2. 如果编写规范的人与编写代码的人不是同一个人,那么编码者尽早参与其中,以确保整个过程在可以有用地完成的范围之内是很重要的。如果规范制定者是编码者,那么这个问题就是学术性的。

  3. 最大的优点是,测试允许在真正实现它们之前,以测试驱动的方式测试代码的接口。主要的缺点是,测试可能会锁定一个可能从进一步演变中受益的设计。

突击测验 – 单元测试

  1. 测试应该在将要测试的代码之前编写,基于对该代码的期望。

  2. 是的。

  3. 应尽可能多地运行测试。在编码时定期运行测试,以及在将代码存储到版本控制系统之前运行测试非常有用。

  4. 你将花费大部分时间使用测试的输出作为工具,帮助你找到和修复代码中的错误。

第四章

突击测验 – Mocker 使用

  1. IN

  2. None作为上限传递。

  3. 它检查模拟对象是否确实以我们描述的方式被使用。

第五章

突击测验 – 基本 unittest 知识

  1. class test_exceptions(TestCase):
     def test_ValueError(self):
      self.assertRaises(ValueError, int, '123')
    
  2. 使用assertAlmostEqual方法。

  3. 如果你没有更适合你需求的更专业化的断言,你会使用assertTrue。如果你需要表达测试失败的复杂条件,以至于它们不适合放入一个单一的布尔表达式中,你会使用fail

突击测验 – 文本固定装置

  1. 为每个测试提供一个相同、独立的运行环境。

  2. setUp和/或tearDown方法添加到TestCase子类中。

  3. 测试固定装置可以由一个或两个方法组成,所以答案是肯定的。

第六章

突击测验 – 使用 Nose 进行测试

  1. 在你的 Nose 配置文件中设置processes=4

  2. 在 Nose 命令行中添加--include="specs"

  3. unit_testsTestFilestest_files将被识别。

第七章

突击测验 - 测试驱动开发

  1. 由于可测试的规范没有遵循单元测试规范,它没有满足我对单元测试的需求。我不得不编写额外的测试来满足这一需求。这样做是可以的,只要我不在真正的单元测试上节省。

  2. 绝对不是。实际上,尽可能频繁地运行你的测试是可取的。

  3. 你失去了在你将代码的计划接口固定之前对其进行测试的机会。你失去了在没有受到你第一次实现的实际行为污染的情况下写下你的期望的机会。你失去了让计算机告诉你需要做什么来创建一个有效实现的机会。

第八章

突击测验 - Twill 语言

  1. 最后被formvalue命令触及的任何形式都会被提交。

  2. notfind命令。

  3. Twill 将报告整个脚本失败,并且不会执行任何后续命令。

突击测验 - 浏览器方法

  1. 你传递给参数的值将与表单的名称、编号和 ID 进行匹配。

  2. clicked方法将模拟输入焦点移动到网页上的新控件。

  3. code命令检查响应代码,如果它与预期值不匹配,则抛出异常。get_code方法简单地返回响应代码。

第九章

突击测验 - 集成测试绘图

  1. 如果这些单元之间没有关联,它们就不会存在于同一个类中。通过将它们视觉上分组到它们的类中,我们可以利用这种关系使我们的图表更容易理解。

  2. 通常,这可以让我们在以后避免麻烦。在某一层面上相互关联的事物往往在更高层面上是同一事物的组成部分。

  3. 在测试中,就像在化学中一样,一次只改变一件事很重要。如果我们一次性拉起两个以上的东西,我们就改变了不止一件事,因此我们可能会失去追踪我们找到的任何问题的来源。

突击测验 - 编写集成测试

  1. 在最小圆圈中的那些,尤其是如果它们没有任何线条指向其他圆圈的话。

  2. 从包含该代码的最小圆圈开始,逐步构建,直到你准备好将其与之前的代码集成。

  3. 当我们进行单元测试时,即使是同一类的其他实例也被模拟了;我们关心的是这段代码是否按预期执行,而不涉及任何其他东西。现在我们正在进行集成测试,我们需要测试同一类的实例是否正确地相互交互,或者当它们被允许从一个操作保留状态到下一个操作时,它们是否与自己正确交互。这两种测试覆盖了不同的事情,所以我们需要两者都有是有道理的。

  4. 系统测试是集成测试的最后阶段。这是一个涉及整个代码库的测试。

第十章

突击测验 - 代码覆盖率

  1. 这意味着在运行测试时,大部分代码库都被执行了。

  2. 那将是一个糟糕的想法,因为覆盖率并不能告诉你测试的质量。它是用来帮助你找到需要测试的东西,而不是用来告诉你测试是否好。

  3. 覆盖率报告提供最有用的信息是未执行的代码行列表,因为那正是让你知道你可能需要添加哪些新测试的原因。

突击测验 - 版本控制钩子

  1. 它可以确保你的测试经常被执行,并且可以在你检查有缺陷的代码时立即让你意识到。

  2. 不要让检查有缺陷的代码变得不可能。不要让版本控制钩子成为公司政策的问题。

  3. 在集中式版本控制系统中,钩子通常由系统管理员控制,并在服务器上执行。在分布式版本控制系统中,钩子通常由用户控制,并在用户的计算机上执行。

posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报