原文:zh.annas-archive.org/md5/94d2594c4830a60d642d681a0ab6b94a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在这本书中,您将了解 Python 3 语言中自动化测试的主要工具、技术和技能。您将了解 Python 标准库中包含的工具,如 doctest、unittest 和 unittest.mock。您还将了解如 Nose 和 coverage.py 等有用的非标准工具。在我们讨论这些工具时,我们还将讨论测试的哲学和最佳实践,因此您完成学习后,将准备好在现实世界项目中应用所学知识。

这本书是早期书籍《Python 测试:入门指南》,作者丹尼尔·阿巴克勒,出版社 Packt Publishing 的后续作品,该书仅涵盖 Python 2.6 版本之前的 Python。Python 3 及其相关工具与本书略有不同,不足以将其称为第二版。如果您已经阅读了早期书籍,并且这本书的某些部分看起来很熟悉,那是因为两本书实际上是相似的。

这本书涵盖的内容

第一章, Python 与测试,介绍了 Python 中的形式化和自动化测试。

第二章, 使用 doctest,教您如何使用 doctest,这是一个将测试和文档集成的工具。

第三章, 使用 doctest 进行单元测试,帮助您理解如何将 doctest 应用于单元测试领域。

第四章, 使用 unittest.mock 解耦单元,教您如何创建和使用模拟对象。

第五章, 使用 unittest 进行结构化测试,帮助您使用 unittest 构建更结构化的测试套件。

第六章, 使用 Nose 运行测试,帮助您通过一条命令运行 doctests、unittests 等。

第七章, 测试驱动开发实践,逐步引导您通过测试驱动开发过程。

第八章, 集成与系统测试,教您如何测试代码单元之间的交互。

第九章, 其他工具和技术,帮助您了解持续集成、版本控制钩子以及其他与测试相关的有用事物。

您需要这本书的什么

您需要 Python 3.4 或更高版本、文本编辑器和互联网访问才能充分利用这本书。

这本书面向谁

本书主要面向那些对 Python 语言有扎实掌握,并希望提高自动化测试能力的人群。如果您对 Python 一无所知,本书仍可作为自动化测试哲学和实践的入门指南。然而,由于 Python 的可执行伪代码特性,您可能会在某些时候发现道路有些崎岖。

惯例

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的意义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名应如下显示:“Mock 对象由标准库中的unittest.mock模块提供。”

代码块应如下设置:

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

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

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

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

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

任何命令行输入或输出都应如下编写:

$ python -m nose

新术语重要词汇以粗体显示。

注意

警告或重要注意事项以如下框的形式出现。

小贴士

小技巧和技巧如下所示。

读者反馈

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

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

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

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有许多事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您在www.packtpub.com的账户下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误

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

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

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

如果您发现涉嫌盗版的内容,请通过发送链接到<copyright@packtpub.com>与我们联系。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

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

第一章。Python 和测试

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

这本书将向你展示一种新的测试方法,这种方法将测试的大部分负担放在了它应该放的地方——计算机上。更好的是,你的测试将帮助你早期发现问题,并告诉你它们的确切位置,这样你就可以轻松地修复它们。你会喜欢自动化测试的简单、有帮助的方法,以及测试驱动开发。

当涉及到测试时,Python 语言拥有一些最好的工具,因此我们将通过利用这些工具来学习如何使测试变得简单、快捷、有趣且富有成效。

本章提供了本书的概述,因此我们将简要讨论以下主题:

  • 测试的级别:单元测试、集成测试和系统测试

  • 接受测试和回归测试

  • 测试驱动开发

测试以娱乐和盈利

这一章开始时有很多宏伟的声明——你会喜欢测试的。你会依赖它来帮助你早期且轻松地消灭 bug。测试将不再成为你的负担,而会成为你愿意去做的事情。如何做到?

回想一下你最近遇到的那个真正令人烦恼的 bug。它可能是任何东西:数据库模式不匹配、糟糕的数据结构,等等。

记得是什么原因导致了 bug 吗?那是一行带有微妙逻辑错误的代码。那个没有按照文档说明执行的功能。无论是什么,都要记住这一点。

想象一下,如果它在正确的时间运行,并且你被告知了它,那么一小块代码就能捕捉到那个 bug。

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

你的 bug 能存活多久?根本不会很久。

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

当你完成时,你将拥有一个高度测试的代码库,你可以非常有信心。你早早地捕捉到 bug 并迅速修复它们。最好的是,你的测试是基于你告诉计算机的以及你希望程序做什么来进行的。毕竟,为什么你应该做,当计算机可以为你做的时候?

我已经有过简单的自动化测试捕捉到从轻微的打字错误到在模式更改后数据库访问代码被危险地遗弃的实例,以及几乎任何可以想象的 bug。测试迅速捕捉到错误并确定了它们的位置。由于它们的存在,避免了大量的努力和麻烦。

花更少的时间进行调试并确保结果,使得编程更有趣。在更短的时间内生产出更高质量的代码,使其更具盈利性。测试套件提供即时反馈,允许你立即运行代码的每一块,而不是等待整个程序处于可以执行的状态。这种快速周转使得编程既令人满意又富有成效。

测试级别

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

单元测试

单元测试是对程序中最小可能的片段进行测试。通常,这意味着单个函数或方法。这里的重点是单独的:如果无法以有意义的方式进一步分割它,那么它就是一个“单元”。

例如,考虑这个函数作为一个单元是有意义的:

def quadratic(a, b, c, x):
   return a * (x ** 2) + b * x + c

前面的函数作为一个单元工作,因为将其拆分成更小的部分既不实际也不实用。

单元测试单独测试一个单元,验证它是否按预期工作,而不考虑程序其他部分可能的行为。这保护每个单元免受其他地方错误带来的 bug 的影响,并使得缩小真正问题所在变得容易。

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

集成测试

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

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

系统测试

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

系统测试非常重要,但没有集成测试和单元测试的支持,它们并不很有用。在你能确定整体之前,你必须确定各个部分。如果某个地方有细微的错误,系统测试会告诉你它存在,但不会告诉你它在哪或如何修复它。你很可能以前经历过这种情况;这可能是你讨厌测试的原因。一个组织良好的测试套件,系统测试几乎成了一种形式。大多数问题都是由单元测试或集成测试发现的,而系统测试只是提供了一种保证,即一切正常。

接受测试

当一个程序最初被指定时,我们决定期望它有什么行为。编写以确认程序实际上确实做了所期望的事情的测试被称为接受测试。接受测试可以在之前讨论的任何级别上编写,但最常见的是在集成或系统级别。

接受测试往往是不按常规从单元测试到集成测试再到系统测试的例外。许多程序规范在相当高的层面上描述程序,接受测试需要与规范在同一级别上操作。系统测试的大部分是接受测试的情况并不少见。

接受测试是很有用的,因为它们为你提供了持续的保证,即你正在创建的程序确实是所指定的程序。

回归测试

回归是指你的代码中曾经正确工作的部分停止工作。这通常是由于代码其他部分的更改破坏了现在有缺陷部分的假设。当这种情况发生时,向你的测试套件中添加可以识别该错误的测试是个好主意。这确保了,如果你再次犯类似的错误,测试套件会立即捕捉到它。

确保工作代码不会出现错误的测试被称为回归测试。它们可以在发现错误之前或之后编写,并且它们为你提供了保证,即你的程序复杂性不会导致错误成倍增加。一旦你的代码通过了单元测试、集成测试或系统测试,你不需要从测试套件中删除这些测试。你可以保留它们,它们将作为额外的回归测试发挥作用,让你知道测试是否停止工作。

测试驱动开发

当你结合本章中我们介绍的所有元素时,你将进入测试驱动开发的领域。在测试驱动开发中,你总是先编写测试。一旦你为即将编写的代码编写了测试,然后才会编写使测试通过的代码。

这意味着你首先要做的事情是编写验收测试。然后你确定你要从程序的哪些单元开始,并编写测试——名义上,这些是回归测试,尽管它们最初捕获的错误是“代码不存在”;这确认了这些单元尚未正确运行。然后你可以编写一些代码,使单元级别的回归测试通过。

这个过程会一直持续到整个程序完成:编写测试,然后编写使测试通过的代码。如果你发现了一个现有测试没有捕获到的错误,首先添加一个测试,然后添加或修改代码以使测试通过。由于早期、轻松且快速地捕获了所有错误,最终结果是程序非常稳固。

你需要 Python

本书假设你具备 Python 编程语言的实用知识,特别是该语言的 3.4 或更高版本。如果你还没有 Python,你可以从www.python.org/下载完整的语言工具包和库,作为一个易于安装的单个包。

小贴士

大多数 Linux 和 Mac OS X 版本已经包含了 Python,但并不一定包含与本书兼容的新版本。通过命令行运行 Python 来检查。

你还需要你的最喜欢的文本编辑器,最好是支持 Python 语言的。流行的编辑器选择包括 emacs、Vim、Geany、gedit 和 Notepad++。对于那些愿意付费的人来说,TextMate 和 Sublime 很受欢迎。

注意

一些这些流行的编辑器有些……异国情调。它们有自己的操作习惯语,并且不像你可能使用过的任何其他程序。它们之所以受欢迎,是因为它们功能强大;尽管如此,它们可能有些奇怪。如果你发现某个编辑器不适合你,只需选择另一个即可。

摘要

在本章中,我们了解了你可以从这本书中学到什么,以及简要地讨论了自动化测试和测试驱动开发的哲学。

我们讨论了测试的不同层次和角色,它们组合起来形成了一个程序完整的测试套件:单元测试、集成测试、系统测试、验收测试和回归测试。我们了解到单元测试是对程序基本组件(如函数)的测试;集成测试是覆盖程序更大范围的测试(如模块);系统测试是覆盖整个程序的测试;验收测试确保程序符合预期;回归测试确保它在开发过程中保持正常工作。

我们讨论了如何通过将测试的大部分负担转移到计算机上来帮助您。您可以告诉计算机如何检查您的代码,而不是自己进行这些检查。这使得在早期和更频繁地检查代码变得方便,让您免于错过您本可能会错过的东西,并帮助您快速定位和修复错误。

我们讨论了测试驱动开发,这是一种先编写测试的纪律,并让它们告诉您为了编写所需的代码需要做什么。我们还简要讨论了为了完成这本书的工作您将需要的开发环境。

现在,我们准备继续使用doctest测试工具进行工作,这是下一章的主题。

第二章。使用 doctest

我们将要查看的第一个测试工具叫做 doctest。这个名字是 “document testing” 或 “testable document” 的缩写。无论如何,它是一个文学工具,旨在使编写测试变得容易,以便计算机和人类都能从中受益。理想情况下,doctest 测试两者,向人类读者提供信息,并告诉计算机期望什么。

将测试和文档混合使用有助于我们:

  • 保持文档与现实的同步

  • 确保测试表达了预期的行为

  • 重复在文档和测试创建中的一些努力

Doctest 的工作最佳之处

doctest 的设计决策使其特别适合在集成和系统测试级别编写验收测试。这是因为 doctest 将仅人类可读的文本与人类和计算机都能阅读的示例混合在一起。这种结构不支持或强制任何测试的形式化,但它能美妙地传达信息,并且仍然为计算机提供了说 这行得通这行不通 的能力。作为额外的奖励,这是你见过的写测试最简单的方法之一。

换句话说,一个 doctest 文件是一个真正优秀的程序规范,你可以随时让计算机检查你的实际代码。API 文档如果以 doctests 的形式编写并与其他测试一起检查,也会受益。你甚至可以在你的 docstrings 中包含 doctests。

从所有这些中你应该得到的基本想法是,doctest 对于人类和计算机都将从中受益的用途来说是非常理想的。

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。在命令提示符下,切换到保存 test.txt 的目录。输入以下命令:

    $ python3 ‑m doctest test.txt
    
  4. 当运行测试时,你应该看到如下输出:https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_02_01.jpg

结果 - 三乘三不等于十

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

在这种情况下,Python 的行为与测试不同,因为根据测试,三乘三等于十。然而,Python 对此表示不同意。由于doctest期望一件事,而 Python 做了不同的事情,doctest向您提供了一个很好的错误报告,显示了如何找到失败的测试,以及实际结果与预期结果之间的差异。报告的底部是一个总结,显示了每个测试文件中失败的测试数量,当你有多个包含测试的文件时,这很有帮助。

doctests 的语法

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

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

doctest模块忽略文件中不属于测试的部分,这意味着你可以在测试之间放置解释性文本、HTML、行图或其他任何你喜欢的元素。我们利用了这一点在之前的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

在运行测试之前,花一点时间考虑一下。它会通过还是失败?它应该通过还是失败?

结果 - 运行了五个测试

正如我们之前讨论的那样,使用以下命令运行测试:

python3 -m doctest test.txt

你应该看到类似这样的结果:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_02_02.jpg

因为我们将新的测试添加到了包含之前测试的同一文件中,所以我们仍然看到通知,说三乘三不等于 10。现在,尽管如此,我们还看到运行了五个测试,这意味着我们的新测试已经运行并且成功了。

为什么是五个测试?就doctest而言,我们向文件中添加了以下三个测试:

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

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

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

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

预期异常

对于测试预期工作正常的情况来说,这些都很好,但同样重要的是要确保当预期失败时,确实会失败。换句话说:有时你的代码应该引发异常,你需要能够编写测试来检查这种行为。

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

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

这还有另一个好处:当你编写测试时,手动确定回溯将看起来如何需要大量的努力,而且对你来说毫无益处。最好是简单地省略它们。

示例 – 检查异常

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

将以下文本插入到你的doctest文件中,就像往常一样,至少空一行:

Here we use doctest's exception syntax to check that Python is correctly enforcing its grammar. The error is a missing ) on the def line.

>>> def faulty(:
...     yield from [1, 2, 3, 4, 5]
Traceback (most recent call last):
SyntaxError: invalid syntax

测试预期会引发异常,所以如果它没有引发异常或引发了错误的异常,它将会失败。确保你理解这一点:如果测试代码执行成功,测试就会失败,因为它预期会有异常。

使用以下 doctest 运行测试:

python3 -m doctest test.txt

结果 – 失败成功

代码中包含语法错误,这意味着这会引发一个SyntaxError异常,这反过来又意味着示例表现如预期;这表示测试通过。

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_02_03.jpg

在处理异常时,通常希望能够使用通配符匹配机制。doctest通过其省略号指令提供了这种功能,我们将在稍后讨论。

预期空白行

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

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

使用指令控制doctest行为

有时,doctest的默认行为使得编写特定的测试变得不方便。例如,doctest可能会查看预期输出和实际输出之间的细微差异,并错误地得出测试失败的结论。这就是doctest指令发挥作用的地方。指令是特殊格式的注释,你可以将其放置在测试源代码之后,并告诉doctest以某种方式改变其默认行为。

指令注释以# doctest:开头,之后是一个逗号分隔的选项列表,这些选项可以启用或禁用各种行为。要启用一种行为,请写一个+(加号符号)后跟行为名称。要禁用一种行为,请写一个(减号符号)后跟行为名称。我们将在以下几节中查看几个指令。

忽略部分结果

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

当你使用省略号时,doctest会扫描直到找到与预期输出中省略号之后文本相匹配的文本,并从那里继续匹配。这可能导致令人惊讶的结果,例如省略号与实际输出的 0 长度部分匹配,或与多行匹配。因此,需要谨慎使用。

示例 – 省略号测试驱动

我们将在几个不同的测试中使用省略号,以更好地了解其工作原理。作为额外的奖励,这些测试还展示了doctest指令的使用。

将以下代码添加到你的test.txt文件中:

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...:...:...'

结果 – 省略号省略

所有测试都通过了,如果没有省略号,它们都会失败。第一个和最后一个测试,其中我们检查了sys.modules中是否存在特定的模块,并确认了特定的格式,同时忽略了字符串的内容,展示了省略号真正有用的场景,因为它让你可以关注输出中有意义的部分,而忽略测试的其余部分。中间的测试展示了当省略号起作用时,不同的输出如何匹配相同的预期结果。

看看最后一个测试。你能想象任何不是 ISO 格式的时间戳的输出,但仍然会匹配示例吗?记住,省略号可以匹配任何数量的文本。

忽略空白

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

doctest 允许您“规范化”空白字符,将预期输出和实际输出中的任何空白字符序列转换为单个空格。然后它会检查这些规范化版本是否匹配。

示例 – 调用正常化

我们将编写一些测试来演示空白规范化是如何工作的。

将以下代码插入到您的 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.\n")
... # doctest: +NORMALIZE_WHITESPACE
This text contains weird spacing.
39

结果 – 空白匹配任何其他空白

尽管第一个测试的结果被拆分成多行以便于人类阅读,第二个测试的结果去除了奇怪的新行和缩进,但这两个测试都通过了,这也是为了人类的便利。

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

注意

您可能注意到了最后一个例子中的值 39。为什么它在那里?这是因为 write() 方法返回写入的字节数,在这个例子中恰好是 39。如果您在一个将 ASCII 字符映射到多个字节的环境中尝试此示例,您将在这里看到不同的数字;这将导致测试失败,直到您更改预期的字节数。

跳过一个示例

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

示例 – 仅人类使用

将以下代码添加到您的 doctest 文件中:

Now we're telling doctest to skip a test

>>> 'This test would fail.' # doctest: +SKIP
If it were allowed to run.

结果 – 它看起来像是一个测试,但实际上不是

在我们将这个最后的例子添加到文件之前,运行文件时 doctest 报告了十三项测试。添加此代码后,doctest 仍然报告十三项测试。将跳过指令添加到代码中完全将其从 doctest 的考虑范围中移除。它既不是一个通过测试,也不是一个失败测试。它根本不是一项测试。

其他指令

如果您发现需要,可以向 doctest 发出许多其他指令。它们不像之前提到的那些指令那样广泛有用,但将来您可能需要其中之一或多个。

注意

所有 doctest 指令的完整文档可以在 docs.python.org/3/library/doctest.html#doctest-options 找到。

Python 3.4 版本中 doctest 的剩余指令如下:

  • DONT_ACCEPT_TRUE_FOR_1: 这使得 doctest 能够区分布尔值和数字

  • DONT_ACCEPT_BLANKLINE: 这移除了对 功能的支持

  • IGNORE_EXCEPTION_DETAIL: 这使得 doctest 只关心异常是否为预期的类型

严格来说,doctest 支持使用指令语法设置的几个其他选项,但它们作为指令没有意义,所以我们在这里忽略它们。

doctest 测试的执行作用域

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

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

那么,我们如何为每个测试提供它自己的执行作用域呢?有几种方法可以实现。一种方法是将每个测试简单地放置在自己的文件中,以及所需的任何解释性文本。从功能上讲,这很好,但如果没有工具帮你找到并运行所有测试,运行测试可能会很痛苦。我们将在后面的章节中讨论这样一个工具(称为 Nose)。这种方法的一个问题是,它会破坏测试对可读文档的贡献这一理念。

为每个测试提供它自己的执行作用域的另一种方法是定义一个函数内的每个测试,如下所示:

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

通过这样做,最终在共享作用域中结束的只有测试函数(这里命名为 test1)。frob 模块和函数内部绑定的任何其他名称都是隔离的,但有一个例外,那就是导入模块内部发生的事情不是隔离的。如果 frob.hash() 方法在 frob 模块内部改变了一个状态,那么当不同的测试再次导入 frob 模块时,这个状态仍然会被改变。

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

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

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

检查你的理解

一旦你决定了这些问题的答案,通过编写一个测试文档并运行它通过 doctest 来检查它们:

  • doctest 是如何识别文档中测试的开始部分的?

  • doctest 是如何知道一个测试会继续到更多行的?

  • doctest 是如何识别测试预期输出的开始和结束的?

  • 你会如何告诉 doctest 你想要将预期的输出跨越多行,即使测试实际上并不是这样输出的?

  • 异常报告中哪些部分被 doctest 忽略了?

  • 当你在测试文件中分配一个变量时,文件中的哪些部分实际上可以“看到”这个变量?

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

  • 我们如何让 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\. A naïve implementation of this function would get very slow as N increased.

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

注意

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

下一句也提供了一些困难,因为要完全测试它,需要将每个正整数都通过 fib() 函数运行,这将花费很长时间(除非计算机最终耗尽内存并迫使 Python 抛出异常)。那么我们如何处理这类事情呢?

最好的解决方案是检查对于一组有效的输入,条件是否成立。Python 标准库中的 random.randrange()random.choice() 函数使得这变得相对容易。

将 doctests 嵌入 docstrings

将 doctests 写入 docstrings 与写入文档文件一样简单。

注意

对于那些不知道的人来说,docstrings 是 Python 的一项特性,允许程序员将文档直接嵌入到他们的源代码中。Python 的 help() 函数就是由 docstrings 驱动的。要了解更多关于 docstrings 的信息,你可以从 Python 教程部分的 docs.python.org/3/tutorial/controlflow.html#documentation-strings 开始。

当在 docstrings 中编写时,doctests 扮演着略微不同的角色。它们仍然允许计算机检查事物是否按预期工作,但看到它们的人通常是使用 Python 交互式外壳在提交代码之前在代码上工作,或者当他们在工作时文本编辑器弹出 docstrings 的程序员。在这种情况下,doctest 最能发挥作用的是提供信息,因此 docstrings 通常不是检查细节的好地方。尽管如此,doctest 在演示常见情况的正确行为时是一个很好的地方。

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

将每个 docstring 分离到自己的执行范围通常意味着我们不需要在 docstrings 中嵌入 doctests 时过多考虑隔离测试。这是幸运的,因为 docstrings 主要用于文档,而隔离测试所需的技巧可能会掩盖其含义。

示例 - 一个嵌入在 docstring 中的 doctest

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

创建一个名为 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

注意

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

运行这些测试与在 doctest 文档中运行测试一样简单:

python3 -m doctest test.py

由于所有测试都通过了,这个命令的输出什么都没有。我们可以通过在命令行中添加详细标志来让它更有趣:

python3 -m doctest -v test.py

结果——代码现在可以自我文档化和自我测试

当我们通过带有详细标志的doctest运行 Python 文件时,我们会看到以下截图所示的输出:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_02_04.jpg

我们将doctest代码直接放在被测试函数的文档字符串中。这是一个展示程序员如何做某事的测试的好地方。它不是一个详细、低级测试的好地方(文档字符串示例代码中的doctest非常详细,用于说明目的,可能过于详细),因为文档字符串需要作为 API 文档——只需看看示例,你就能看到原因,doctests 占据了文档字符串的大部分空间,而没有告诉读者比单个测试更多的信息。

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

你可能想知道那条写着1 items had no tests的行,以及随后的只写着test的行。这些行指的是模块级文档字符串中没有编写测试的事实。这有点令人惊讶,因为我们根本没在我们的源代码中包含这样的文档字符串,直到你意识到,就 Python(以及因此doctest)而言,没有文档字符串与空文档字符串是相同的。

将其付诸实践——AVL 树

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

注意

虽然 AVL 树与 Python 字典类似,但它们有一些显著不同的特性。首先,存储在 AVL 树中的键可以按排序顺序迭代,且没有额外开销。另一个区别是,在 AVL 树中插入和删除对象通常比 Python dict慢,但在最坏情况下却更快。

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

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

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_02_05.jpg

我们将在这里编写 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 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 value 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 类的构造函数。根据这段描述,构造函数有两种操作模式:它可以创建一个初始化的单个节点,或者它可以基于字典的内容创建和初始化整个节点树。

单节点模式的测试很简单。我们将在第四段之后添加它:

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

我们甚至不需要编写预期结果,因为我们编写了函数,如果存在问题则抛出AssertionError,如果没有问题则返回NoneAssertionError由测试代码中的assert语句触发,如果assert语句中的表达式产生一个假值。

第二种模式的测试看起来同样简单,我们将在其他测试之后添加它:

>>> valid_tree(AVL({1: 'Hello', 2: 'World', -3: '!'}))

然而,这里有一些隐藏的复杂性。几乎可以肯定,这个构造函数将通过初始化单个节点,然后使用该节点的set方法将剩余的键和值添加到树中来工作。这意味着我们的第二个构造函数测试不是一个单元测试,而是一个集成测试,它检查多个单元之间的交互。

规范文档通常包含集成级和系统级测试,所以这并不是真正的问题。然而,需要注意的是,如果这个测试失败,它并不一定会显示问题真正所在的地方。你的单元测试会做到这一点。

另一点需要注意是,我们没有检查构造函数在接收到不良输入时是否失败。这些测试非常重要,但英文规范完全没有提到这些点,这意味着它们实际上并不在验收标准中。我们将把这些测试添加到单元测试套件中。

重新计算高度

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

现在我们有了 make_test_tree() 函数,测试 recalculate_height() 就变得简单了:

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

使节点可删除

规范的第六段描述了 make_deletable() 方法。你不能删除有子节点的节点,因为这会使节点的小孩与树的其他部分断开连接。考虑一下我们之前看过的包含动物名称的树。如果我们从树的底部删除 Elephant 节点,我们该如何处理 AardvarkGooseFrog?如果我们删除 Goose,我们之后如何找到 Frog

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_02_05.jpg

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

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

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

旋转

规范第七段中描述的两个旋转函数对树中的链接进行了一些复杂的操作。你可能发现他们对所做操作的简单语言描述有点令人困惑。在这些时候,一点点的代码比任何数量的句子都要有意义得多。

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

>>> tree = make_test_tree()
>>> tree.value
'seven'
>>> tree.left.value
'three'
>>> tree.rotate_counterclockwise()
>>> tree.value
'three'
>>> tree.left is None
True
>>> 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

定位一个节点

根据规范的第 8 段,locate()方法预期返回一个节点,或者在键存在于树中时抛出KeyError异常。我们将再次使用我们专门构建的测试树,以便我们确切地知道树的结构如下:

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

小贴士

下载示例代码

您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

规范的其余部分

规范的剩余段落描述了通过调用已描述的函数来操作的高级函数。这意味着,在我们学习到第四章中模拟对象的技巧之前,我们在这里将不得不编写集成级别的测试。正如我之前提到的,在规范文档中这样做并不是什么坏事,所以我们将继续这样做:

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.

>>> tree = make_test_tree()
>>> tree.get(10)
'ten'
>>> tree.get(97) # doctest: +ELLIPSIS
Traceback (most recent call last):
KeyError: ...

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

>>> tree = make_test_tree()
>>> tree.set(10, 'foo')
>>> tree.locate(10).value
'foo'

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.

>>> tree = make_test_tree()
>>> tree.remove(3)
>>> tree.remove(3) # doctest: +ELLIPSIS
Traceback (most recent call last):
KeyError: ...

摘要

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

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

现在我们已经了解了doctest,我们就可以讨论如何使用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))

这里有一些需要考虑的事情:这段代码的哪些部分是单元?是否只有一个包含整个类的单元?每个方法是否是一个单独的单元?关于每个语句,或者可能是每个表达式呢?

在某种意义上,答案具有主观性,因为单元定义的一部分是它是有意义的。你可以认为整个类是一个单元,在某些情况下这可能是最合适的答案。然而,大多数类都很容易细分为方法,通常方法比类更适合作为单元,因为它们有定义良好的接口和部分隔离的行为,以及它们的意图和意义应该被充分理解。

语句和表达式并不构成好的单元,因为它们几乎从未在孤立状态下具有特别的意义。此外,语句和表达式很难定位:与类和方法不同,它们没有名称或容易聚焦测试的方法。

这里有一些需要考虑的事情:选择不同的单元定义对这段代码会有什么后果?如果你已经决定方法是最好的单元,那么如果你选择了类,会有什么不同?同样,如果你选择了类,那么如果你选择了方法,会有什么不同?

这里有一些需要考虑的事情:看看method4。这个方法的结果依赖于所有其他方法正确工作;更糟糕的是,它依赖于 self 对象的唯一 ID。method4能否被视为一个单元?如果我们可以改变除method4之外的所有东西,我们还需要改变什么才能允许它作为一个单元进行测试并产生可预测的结果?

选择单元

在决定什么是单元之前,你不能组织单元测试套件。你选择的编程语言的能力会影响这个选择。例如,C++和 Java 使得将方法视为单元变得困难或不可能(因为你不能在没有首先实例化它所属的类的情况下访问方法);因此,在这些语言中,每个类通常被视为一个单独的单元,或者使用元编程技巧来强制方法隔离,以便它们可以作为单元进行测试。另一方面,C 语言根本不支持类作为语言特性,所以明显的单元选择是函数。Python 足够灵活,既可以考虑类或方法作为单元,当然,它也有独立的函数;将它们视为单元也是自然的。

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

当你在思考method4时,你可能意识到对idself.method3的函数调用是问题所在,如果它们没有调用其他单元,那么这个方法可以作为单元进行测试。在 Python 中,在运行时用替身替换函数相对容易,我们将在下一章讨论这种方法的结构化方法。

检查你的理解

看看这个简单类的代码,并使用它来找出问题的答案。查看本书是完全可以的。这只是确保你准备好继续前进的一种方式:

class ClassOne:
    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. 假设我们使用方法作为单元,前面代码中存在多少个单元?

    答案:在前面代码中存在三个单元,如下所示:__init__method1method2__init__是一个方法,就像method1method2一样。它是构造函数的事实意味着它与其他单元纠缠在一起,但它仍然是一个包含代码和可能存在错误的位置的方法,所以我们不能将其视为任何其他东西,而只能视为一个单元。

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

    答案:method1method2都假设__init__运行正确,method2method1有相同的假设。

  3. 如何编写一个测试method2的测试,而不假设其他单元正确工作?

    答案:method2的测试将需要使用一个作为测试代码一部分的假method1,而不是被测试代码的一部分。

开发过程中的单元测试

我们将逐步开发一个类,将其视为一个完整的编程项目,并在每个步骤中集成单元测试。对于一个如此小的独立类来说,这可能看起来很愚蠢,但它说明了如何防止大型项目陷入 bug 的混乱。

我们将创建一个 PID 控制器类。PID 控制器是控制理论中的一个工具,是一种控制机器使其平滑高效运动的方法。在工厂中组装汽车的机器人手臂是由 PID 控制器控制的。我们将使用 PID 控制器进行这个演示,因为它非常实用,并且非常贴近现实世界。许多程序员在他们的职业生涯中某个时刻都被要求实现 PID 控制器。这个例子意味着我们作为承包商,正在被支付以产生结果。

注意

如果你发现 PID 控制器比编程书中的简单例子更有趣,维基百科的文章是一个开始学习这个主题的好地方:en.wikipedia.org/wiki/PID_controller

设计

我们的假设客户给出了以下规范:

我们希望有一个类来实现单个变量的 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

我们还需要编写描述测量处理的测试。这意味着测试控制器的实际使用,以测量值作为其输入,并产生一个控制信号,该信号应平滑地将测量变量移动到设定点。

PID 控制器的行为基于时间;我们知道这一点,所以如果我们希望测试产生可预测的结果,我们需要能够向控制器提供我们选择的时间值。我们通过用具有相同签名的不同函数替换time.time来实现这一点,该函数产生可预测的结果。

一旦我们处理完这个问题,我们就将我们的测试输入值插入到定义 PID 控制器的数学公式中,包括增益,以确定正确的输出值,并使用这些数字来编写测试:

Replace time.time with a predictable fake
>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(1, 1000)).__next__

Make sure we're not inheriting old state from the constructor tests
>>> import imp
>>> pid = imp.reload(pid)

Actual tests. These test values are nearly arbitrary, having been chosen for no reason other than that they should produce easily recognized values.
>>> controller = pid.PID(P=0.5, I=0.5, D=0.5, setpoint=0)
>>> controller.calculate_response(12)
-6.0
>>> 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

Undo the fake
>>> time.time = real_time

我们需要编写描述设定点处理的测试。我们的客户要求一个“记忆”设定点,我们将将其解释为栈,因此我们编写测试以确保设定点栈正常工作。编写使用此栈行为的代码使我们注意到,没有设定点的 PID 控制器不是一个有意义的实体,因此我们添加了一个测试来检查 PID 类通过引发异常来拒绝这种情况:

>>> pid = imp.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

PID 控制器在其他地方有明确的定义,所以我们的客户给出的稀疏规范在整个过程中工作得相当好。尽管如此,当我们编写验收测试时,我们不得不明确几个假设;在测试运行之前,与客户核对并确保我们没有走偏可能是个明智之举,这意味着,在我们甚至运行测试之前,它们已经通过指出我们需要向他们提出的问题来帮助我们。

在测试中,我们采取了额外措施来帮助它们彼此隔离,通过在每组测试语句之前强制pid模块重新导入。这会重置模块中可能发生变化的任何内容,并使其重新导入它所依赖的任何模块。这尤其重要,因为我们用模拟函数替换了time.time。我们想确保pid模块使用模拟时间函数,所以我们重新加载了pid模块。如果使用真实时间函数而不是模拟函数,测试将不会有用,因为它只会成功一次。测试需要可重复。

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

虽然我们确实有点偷懒,因为我们没有费心将各种测试与 PID 构造函数隔离开来。如果构造函数中有一个错误,它可能会在任何依赖于它的测试中引起错误的报告。我们本可以使用模拟对象而不是实际的 PID 对象来更加严谨,甚至可以在测试其他单元时跳过调用构造函数,但因为我们直到下一章才讨论模拟对象,所以我们在这里允许自己有点偷懒。

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

小贴士

注意我说的是“你想要准备好测试”,而不是“你想要准备好所有的测试”。在你开始编写代码之前,你不需要或不需要所有测试都到位。你想要的是在过程开始时就确定好定义你已知的事情的测试。

开发

现在我们有一些测试了,我们可以开始编写代码以满足测试,从而也满足规范。

小贴士

如果代码已经编写好了呢?我们仍然可以为它的单元编写测试。这并不像与代码并行编写测试那样高效,但至少这给了我们一种检查假设并确保我们没有引入回归的方法。比没有测试套件要好。

第一步是运行测试,因为这是你需要决定下一步做什么时做的第一件事。如果所有测试都通过,要么你已经完成了程序,要么你需要编写更多的测试。如果一个或多个测试失败,你选择一个并让它通过。

因此,我们按照以下方式运行测试:

python3 -m doctest PID.txt

第一次他们告诉我们我们没有pid模块。让我们创建一个并填充一个PID类的第一次尝试:

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 calculate_response(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 += err * delta
            result += I * self.integrated_error
            result += D * (err - self.previous_error) / delta

        self.previous_error = err
        self.previous_time = now

        return result

现在,我们将再次运行测试,看看我们做得如何:

python3 -m doctest PIDflawed.txt

这立即告诉我们calculate_response方法中有一个错误:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_03_01.jpg

有更多类似的错误报告。总共有五个。似乎calculate_response方法在反向工作,当它应该给出正值时产生了负值,反之亦然。

我们知道我们需要在calculate_response中查找符号错误,我们发现在第四行,输入值应该从设定点减去,而不是相反。如果我们把这一行改为以下内容,事情应该会更好:

err = self.setpoint[-1] - value

如预期的那样,这个更改修复了问题。现在所有的测试都通过了。

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

无论何时我们发现一个测试尚未捕获的 bug,正确的做法是添加一个测试来捕获它,然后我们需要修复这个 bug。这会给我们一个固定的 bug,但也会有一个覆盖之前未测试过的程序部分的测试。你的新测试可能会捕获你甚至没有意识到的更多 bug,并且它将帮助你避免重新创建已修复的 bug。

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

注意,测试失败的模式立即显而易见。当然,没有保证这种情况会发生,但通常是这样的。结合能够将注意力缩小到有问题的特定单元,调试通常变得非常简单。

反馈

因此,我们有一个 PID 控制器,它通过了我们的测试…我们完成了吗?也许吧。让我们去询问客户。

好消息是,他们大多数都喜欢。尽管如此,他们还有一些想要改变的地方。他们希望我们能够可选地指定当前时间作为calculate_response的参数,以便使用指定的时间而不是当前系统时间。他们还希望我们更改构造函数的签名,使其接受初始测量值和可选的测量时间作为参数。

因此,程序通过了我们所有的测试,但测试不再正确描述需求了。怎么办?

首先,我们将初始值参数添加到构造函数测试中,并更新预期结果如下:

>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(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

现在,我们将为构造函数添加另一个测试,这是一个检查当提供可选的初始时间参数时的正确行为的测试:

>>> import imp
>>> pid = imp.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

接下来,我们将calculate_response测试更改为使用构造函数的新签名:

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

我们需要添加第二个calculate_response测试,以检查当将可选的时间参数传递给它时,函数是否表现正常:

>>> pid = imp.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

最后,我们在设定点方法测试中调整构造函数调用。这个更改看起来与其他测试中的构造函数调用更改相同。

当我们调整测试时,我们发现由于将初始值和初始时间参数添加到构造函数中,calculate_response方法的行为发生了变化。测试将报告这是一个错误,但并不清楚这实际上是否错误,所以我们与客户进行了确认。经过讨论,客户决定这实际上是正确的行为,因此我们更改了我们的测试以反映这一点。

我们完整的规范和测试文档现在看起来是这样的(新或更改的行已突出显示):

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 range(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.

>>> import imp
>>> pid = imp.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

The calculate response method receives the measured value as input,
and returns the control signal.

>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(1, 1000)).__next__
>>> pid = imp.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 = imp.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 = imp.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

我们的测试没有符合要求,因此我们需要更改它们。这很好,但我们不想更改太多,因为我们已有的测试帮助我们避免了我们之前发现或必须解决的问题。我们最不想看到的是计算机停止检查已知问题。因此,我们非常倾向于添加新的测试,而不是更改旧的测试。

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

将初始参数添加到构造函数中是一件大事。这不仅改变了构造函数应该如何表现,还以相当戏剧性的方式改变了calculate_response方法应该如何表现。由于正确行为发生了变化(这是我们直到测试指出这一点之前都没有意识到的事实,这也反过来允许我们在开始编写代码之前从客户那里获得对正确行为的确认),我们别无选择,只能遍历并更改测试,重新计算预期的输出等。尽管如此,所有这些工作都有好处,除了未来能够检查函数是否正确工作之外:这使得我们在实际编写函数时更容易理解函数应该如何工作。

当我们更改测试以反映新的正确行为时,我们仍然尽量少做更改。毕竟,我们不想让测试停止检查仍然正确的旧行为,我们也不想在自己的测试中引入错误。

小贴士

在一定程度上,正在测试的代码充当了测试的测试,因此即使你的测试中存在错误,当使用良好的测试纪律时,这些错误也不会持续很长时间。

再次进行开发

是时候进行更多编码了。在现实生活中,我们可能会根据我们与客户沟通的好坏在开发和反馈之间循环多次。事实上,增加我们来回次数的数量可能是一件好事,即使这意味着每个周期都很短。让客户保持同步并了解最新情况是件好事。

第一步,一如既往,是运行测试并获取需要完成的更新列表:

Python3 -m doctest PID.txt

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_03_02.jpg

实际上报告的错误还有很多,但第一个错误就给我们提供了很好的提示,关于我们需要立即修复的问题。构造函数需要改变以匹配测试的期望。

使用doctest错误报告来引导我们,并频繁地重新运行测试,我们可以快速将我们的 PID 类调整到合适的状态。在实践中,这在使用短的开发周期时效果最好,你只对代码进行少量更改,然后再次运行测试。修复一个问题,然后再次测试。

一旦我们在编码和测试之间来回多次,我们最终会得到类似这样的结果:

from time import time

class PID:
    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

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

再次强调,所有测试都通过了,包括来自客户的修订测试,而且没有错误报告是多么令人欣慰。我们准备看看客户是否愿意接受代码的交付。

流程的后期阶段

在开发的后期阶段,你的任务是维护代码,或者将其集成到另一个产品中。功能上,它们与开发阶段相同。如果你正在处理现有的代码并被要求维护或集成它,如果它已经附带了一个测试套件,你会感到更加高兴,因为,直到你掌握了代码的复杂性,测试套件是你唯一能够有信心修改代码的方式。

如果你不幸得到了一堆没有测试的代码,编写测试是一个好的第一步。你写的每个测试都是代码的一个单元,你可以诚实地说你理解它,并知道可以期待什么。当然,你写的每个测试都是另一个单元,你可以依赖它告诉你是否引入了错误。

摘要

我们已经通过使用单元测试和测试驱动开发来开发项目的过程,注意我们如何识别单元,并介绍了一些我们可以隔离doctest测试以针对单个单元的方法。

我们还讨论了单元测试的哲学和纪律,详细说明了它是什么,以及为什么它有价值。

在下一章中,我们将讨论模拟对象,这是隔离单元的强大工具。

第四章。使用 unittest.mock 解耦单元

在过去几章中,有好几次在面临将测试相互隔离的问题时,我告诉你们只需将问题记在心里,并说我们会在本章中处理它。终于,是时候真正解决这个问题了。

不依赖于其他函数、方法或数据的行为的函数和方法很少见;常见的情况是它们会调用其他函数或方法多次,并至少实例化一个类。这些调用和实例化中的每一个都会破坏单元的隔离;或者,如果你更喜欢这样想,它将更多的代码纳入了隔离部分。

无论你怎么看它——是作为隔离破坏者还是作为扩展隔离部分——这都是你想要有能力防止的事情。模拟对象通过取代外部函数或对象让你能够做到这一点。

使用unittest.mock包,你可以轻松执行以下操作:

  • 用我们第三章中用到的time.time一样,替换你自己的代码或外部包中的函数和对象。

  • 控制替换对象的行为。你可以控制它们提供的返回值,是否抛出异常,甚至是否调用其他函数,或创建其他对象的实例。

  • 检查替换对象是否按预期使用:函数或方法是否被正确次数地调用,调用是否按正确的顺序发生,以及传递的参数是否正确。

一般的模拟对象

好吧,在我们深入探讨unittest.mock的细节之前,让我们花几分钟时间来谈谈模拟对象的整体情况。

从广义上讲,模拟对象是你可以在测试代码中使用作为替代品的对象,以防止测试重叠,并确保被测试的代码不会渗透到错误的测试中。因此,我们来自第三章的假time.time使用 doctest 进行单元测试,就是一个模拟对象。然而,就像编程中的大多数事情一样,当它被正式化为一个设计良好的库,你可以在需要时调用它时,这个想法会更好。大多数编程语言都有许多这样的库可用。

随着时间的推移,模拟对象库的作者已经为模拟对象开发了两种主要的设计模式:在一种模式中,你可以创建一个模拟对象,并对其执行所有预期的操作。对象记录这些操作,然后你将对象放入回放模式,并将其传递给你的代码。如果你的代码未能复制预期的操作,模拟对象会报告失败。

在第二种模式中,你可以创建一个模拟对象,进行必要的最小配置以允许它模仿它所替代的真实对象,并将其传递给代码。它会记录代码如何使用它,然后你可以在事后执行断言来检查代码是否按预期使用对象。

在使用它编写的测试方面,第二种模式在功能上略胜一筹,但总体上,两种模式都工作得很好。

根据 unittest.mock 的模拟对象

Python 有几个模拟对象库;然而,截至 Python 3.3,其中一个已经成为标准库的成员。自然地,我们将关注这个库。这个库当然是unittest.mock

unittest.mock库是第二种类型,一种记录实际使用情况然后断言的库。该库包含几种不同的模拟对象,它们共同让你可以模拟 Python 中几乎任何存在的东西。此外,该库还包含几个有用的辅助工具,简化了与模拟对象相关的各种任务,例如临时用模拟对象替换真实对象。

标准模拟对象

unittest.mock的基本元素是unittest.mock.Mock类。即使没有进行任何配置,Mock实例也能很好地模仿其他对象、方法或函数。

注意

对于 Python,有许多模拟对象库;严格来说,“模拟对象”这个短语可能意味着由这些库中的任何一个创建的对象。从现在起,在这本书中,你可以假设“模拟对象”是unittest.mock.Mock或其子类的实例。

模拟对象能够通过一个巧妙且有些递归的技巧来完成这种模仿。当你访问模拟对象的未知属性时,而不是抛出AttributeError异常,模拟对象会创建一个子模拟对象并返回它。由于模拟对象擅长模仿其他对象,返回模拟对象而不是实际值在常见情况下是有效的。

同样,模拟对象是可调用的;当你将模拟对象作为函数或方法调用时,它会记录调用参数,然后默认返回一个子模拟对象。

一个子模拟对象是一个独立的模拟对象,但它知道它与它所来的模拟对象——它的父对象相连。你对子对象所做的任何操作也会记录在父对象的记忆中。当需要检查模拟对象是否被正确使用时,你可以使用父对象来检查其所有后代。

示例:在交互式外壳中玩转模拟对象(亲自试试!)

$ python3.4
Python 3.4.0 (default, Apr  2 2014, 08:10:08)
[GCC 4.8.2] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from unittest.mock import Mock, call
>>> mock = Mock()
>>> mock.x
<Mock name='mock.x' id='140145643647832'>
>>> mock.x
<Mock name='mock.x' id='140145643647832'>
>>> mock.x('Foo', 3, 14)
<Mock name='mock.x()' id='140145643690640'>
>>> mock.x('Foo', 3, 14)
<Mock name='mock.x()' id='140145643690640'>
>>> mock.x('Foo', 99, 12)
<Mock name='mock.x()' id='140145643690640'>
>>> mock.y(mock.x('Foo', 1, 1))
<Mock name='mock.y()' id='140145643534320'>
>>> mock.method_calls
[call.x('Foo', 3, 14),
 call.x('Foo', 3, 14),
 call.x('Foo', 99, 12),
 call.x('Foo', 1, 1),
 call.y(<Mock name='mock.x()' id='140145643690640'>)]
>>> mock.assert_has_calls([call.x('Foo', 1, 1)])
>>> mock.assert_has_calls([call.x('Foo', 1, 1), call.x('Foo', 99, 12)])
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/usr/lib64/python3.4/unittest/mock.py", line 792, in assert_has_calls
 ) from cause
AssertionError: Calls not found.
Expected: [call.x('Foo', 1, 1), call.x('Foo', 99, 12)]
Actual: [call.x('Foo', 3, 14),
 call.x('Foo', 3, 14),
 call.x('Foo', 99, 12),
 call.x('Foo', 1, 1),
 call.y(<Mock name='mock.x()' id='140145643690640'>)]
>>> mock.assert_has_calls([call.x('Foo', 1, 1),...                        call.x('Foo', 99, 12)], any_order = True)
>>> mock.assert_has_calls([call.y(mock.x.return_value)])
>>>

在这个交互式会话中展示了几个重要的事情。

首先,注意每次我们访问mock.x时都返回了相同的模拟对象。这始终成立:如果你访问模拟对象的相同属性,你将得到相同的模拟对象作为结果。

下一个需要注意的事情可能看起来更令人惊讶。无论何时调用模拟对象,你都会得到相同的模拟对象作为返回值。返回的模拟对象不是为每次调用而创建的,也不是为每个参数组合而唯一的。我们很快就会看到如何覆盖返回值,但默认情况下,每次调用模拟对象时,你都会得到相同的模拟对象。你可以使用 return_value 属性名来访问这个模拟对象,正如你可能从示例的最后一句中注意到的。

unittest.mock 包包含一个 call 对象,它有助于更容易地检查是否已经进行了正确的调用。call 对象是可调用的,并以类似于模拟对象的方式记录其参数,这使得它很容易与模拟对象的调用历史进行比较。然而,call 对象真正闪耀的时候是你必须检查对派生模拟对象的调用。正如你可以在前面的例子中看到,call('Foo', 1, 1) 将匹配对父模拟对象的调用,但如果调用使用了这些参数,call.x('Foo', 1, 1),它将匹配对名为 x 的子模拟对象的调用。你可以构建一个长长的查找和调用链。例如:

>>> mock.z.hello(23).stuff.howdy('a', 'b', 'c')
<Mock name='mock.z.hello().stuff.howdy()' id='140145643535328'>
>>> mock.assert_has_calls([
...     call.z.hello().stuff.howdy('a', 'b', 'c')
... ])
>>>

注意,原始调用包括了 hello(23),但调用规范只是简单地将其写成 hello()。每个调用规范只关心最终被调用对象的参数。中间调用的参数不被考虑。这没关系,因为它们总是产生相同的返回值,除非你覆盖了这种行为,在这种情况下,它们可能根本不会产生模拟对象。

注意

你可能之前没有遇到过断言。断言只有一个任务,而且只有一个任务:如果某事不是预期的,它会引发异常。特别是 assert_has_calls 方法,如果模拟对象的历史记录不包括指定的调用,则会引发异常。在我们的例子中,调用历史记录是一致的,所以断言方法没有做任何明显的操作。

尽管如此,你可以检查中间调用是否使用了正确的参数,因为模拟对象在记录对 mock.z.hello(23) 的调用之前立即记录了对 mock.z.hello().stuff.howdy('a', 'b', 'c') 的调用:

>>> mock.mock_calls.index(call.z.hello(23))
6
>>> mock.mock_calls.index(call.z.hello().stuff.howdy('a', 'b', 'c'))
7

这也指出了所有模拟对象都携带的 mock_calls 属性。如果各种断言函数对你来说还不够用,你总是可以编写自己的函数来检查 mock_calls 列表,并验证事情是否如预期那样。我们很快就会讨论模拟对象断言方法。

非模拟属性

如果你希望模拟对象在查找属性时返回的不仅仅是子模拟对象,怎么办?这很简单;只需将值分配给该属性:

>>> mock.q = 5
>>> mock.q
5

另有一个常见的情况是模拟对象的默认行为是错误的:如果访问特定属性应该引发一个AttributeError怎么办?幸运的是,这也很简单:

>>> del mock.w
>>> mock.w
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/usr/lib64/python3.4/unittest/mock.py", line 563, in __getattr__
 raise AttributeError(name)
AttributeError: w

非模拟返回值和引发异常

有时,实际上相当频繁,您会希望模拟对象扮演函数或方法的角色,返回特定的值或一系列特定的值,而不是返回另一个模拟对象。

要使模拟对象始终返回相同的值,只需更改return_value属性:

>>> mock.o.return_value = 'Hi'
>>> mock.o()
'Hi'
>>> mock.o('Howdy')
'Hi'

如果您希望模拟对象在每次调用时返回不同的值,您需要将一个返回值序列分配给side_effect属性,如下所示:

>>> mock.p.side_effect = [1, 2, 3]
>>> mock.p()
1
>>> mock.p()
2
>>> mock.p()
3
>>> mock.p()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/usr/lib64/python3.4/unittest/mock.py", line 885, in __call__
 return _mock_self._mock_call(*args, **kwargs)
 File "/usr/lib64/python3.4/unittest/mock.py", line 944, in _mock_call
 result = next(effect)
StopIteration

如果您不希望模拟对象引发StopIteration异常,您需要确保为测试中的所有调用提供足够的返回值。如果您不知道它将被调用多少次,一个无限迭代器,如itertools.count可能就是您需要的。这很容易做到:

>>> mock.p.side_effect = itertools.count()

如果您希望模拟在返回值而不是引发异常,只需将异常对象分配给side_effect,或者将其放入分配给side_effect的迭代器中:

>>> mock.e.side_effect = [1, ValueError('x')]
>>> mock.e()
1
>>> mock.e()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/usr/lib64/python3.4/unittest/mock.py", line 885, in __call__
 return _mock_self._mock_call(*args, **kwargs)
 File "/usr/lib64/python3.4/unittest/mock.py", line 946, in _mock_call
 raise result
ValueError: x

side_effect属性还有另一个用途,我们将在后面讨论。

模拟类或函数的细节

有时,模拟对象的一般行为并不能足够接近被替换对象的模拟。这尤其适用于当它们在不当使用时需要引发异常的情况,因为模拟对象通常乐于接受任何使用方式。

unittest.mock包使用一种称为speccing的技术来解决此问题。如果你将一个对象传递给unittest.mock.create_autospec,返回的值将是一个模拟对象,但它会尽力假装它就是传递给create_autospec的那个对象。这意味着它将:

  • 如果您尝试访问原始对象没有的属性,将引发一个AttributeError,除非您首先明确地为该属性分配一个值。

  • 如果您尝试在原始对象不可调用时调用模拟对象,将引发一个TypeError

  • 如果传递了错误的参数数量或传递了在原始对象可调用时不合理的关键字参数,将引发一个TypeError

  • 欺骗isinstance认为模拟对象是原始对象类型的

create_autospec创建的模拟对象与其所有子对象也共享这一特性,这通常是您想要的。如果您确实只想让特定的模拟对象被 specced,而其子对象不是,您可以使用spec关键字将模板对象传递给Mock构造函数。

这里是一个使用create_autospec的简短演示:

>>> from unittest.mock import create_autospec
>>> x = Exception('Bad', 'Wolf')
>>> y = create_autospec(x)
>>> isinstance(y, Exception)
True
>>> y
<NonCallableMagicMock spec='Exception' id='140440961099088'>

模拟函数或方法副作用

有时,为了使模拟对象能够成功地取代一个函数或方法,模拟对象实际上必须调用其他函数,设置变量值,或者一般地执行函数可以做的任何事情。

这种需求不如你想象的那么常见,而且对于测试目的来说也有些危险,因为当你的模拟对象可以执行任意代码时,它们可能会停止成为强制测试隔离的简化工具,反而成为问题的一个复杂部分。

话虽如此,仍然有需要模拟函数执行比简单地返回值更复杂操作的情况,我们可以使用模拟对象的 side_effect 属性来实现这一点。我们之前已经见过 side_effect,当时我们给它分配了一个返回值序列。

如果你将一个可调用对象分配给 side_effect,当模拟对象被调用并传递相同的参数时,这个可调用对象将被调用。如果 side_effect 函数引发异常,模拟对象也会这样做;否则,模拟对象会返回 side_effect 的返回值。

换句话说,如果你将一个函数分配给模拟对象的 side_effect 属性,这个模拟对象实际上就变成了那个函数,唯一的区别是模拟对象仍然记录了它的使用细节。

side_effect 函数中的代码应该是最小的,并且不应该尝试实际执行模拟对象所替代的代码的工作。它应该做的只是执行任何预期的外部可见操作,然后返回预期的 result.Mock 对象断言方法。

正如我们在 标准模拟对象 部分所看到的,你总是可以编写代码来检查模拟对象的 mock_calls 属性,以查看事物是否按预期运行。然而,已经为你编写了一些特别常见的检查,这些检查作为模拟对象的断言方法提供。对于断言来说,这些断言方法在通过时返回 None,在失败时引发 AssertionError

assert_called_with 方法接受任意集合的参数和关键字参数,除非这些参数在上次调用模拟对象时被传递,否则会引发 AssertionError

assert_called_once_with 方法的表现类似于 assert_called_with,除了它还会检查模拟对象是否只被调用了一次。如果这不是真的,则会引发 AssertionError

assert_any_call 方法接受任意参数和关键字参数,如果模拟对象从未使用这些参数被调用,则会引发 AssertionError

我们已经看到了assert_has_calls方法。此方法接受一个调用对象列表,检查它们是否以相同的顺序出现在历史记录中,如果不出现,则引发异常。请注意,“以相同的顺序”并不一定意味着“相邻”。只要列出的调用都按正确的顺序出现,列表之间可以有其他调用。如果你将any_order参数设置为 true 值,则此行为会改变。在这种情况下,assert_has_calls不会关心调用的顺序,只检查它们是否都出现在历史记录中。

assert_not_called方法如果模拟对象曾被调用,将引发异常。

模拟具有特殊行为的容器和对象

Mock类没有处理的是 Python 特殊语法构造背后的所谓魔法方法:__getitem____add__等等。如果你需要你的模拟对象记录并响应魔法方法——换句话说,如果你想它们假装是字典或列表等容器对象,或者响应数学运算符,或者作为上下文管理器或任何其他将语法糖转换为方法调用的东西——你将使用unittest.mock.MagicMock来创建你的模拟对象。

由于它们(和模拟对象)的工作细节,有一些魔法方法甚至不被MagicMock支持:__getattr____setattr____init____new____prepare____instancecheck____subclasscheck____del__

这里有一个简单的例子,我们使用MagicMock创建一个支持in运算符的模拟对象:

>>> from unittest.mock import MagicMock
>>> mock = MagicMock()
>>> 7 in mock
False
>>> mock.mock_calls
[call.__contains__(7)]
>>> mock.__contains__.return_value = True
>>> 8 in mock
True
>>> mock.mock_calls
[call.__contains__(7), call.__contains__(8)]

其他魔法方法的工作方式也类似。例如,加法:

>>> mock + 5
<MagicMock name='mock.__add__()' id='140017311217816'>
>>> mock.mock_calls
[call.__contains__(7), call.__contains__(8), call.__add__(5)]

注意,加法操作的返回值是一个模拟对象,它是原始模拟对象的子对象,但in运算符返回了一个布尔值。Python 确保某些魔法方法返回特定类型的价值,如果不符合该要求,将引发异常。在这些情况下,MagicMock的方法实现返回一个最佳猜测的正确类型值,而不是子模拟对象。

在使用原地数学运算符(如+=__iadd__)和|=__ior__))时,你需要小心的一点是,MagicMock处理它们的方式有些奇怪。它所做的仍然是有用的,但它可能会让你感到意外:

>>> mock += 10
>>> mock.mock_calls
[]

那是什么?它删除了我们的通话记录吗?幸运的是,没有。它所做的只是将通过加法操作创建的子模拟对象分配给名为 mock 的变量。这完全符合原地数学运算符应有的工作方式。不幸的是,它仍然使我们失去了访问通话记录的能力,因为我们不再有一个指向父模拟对象的变量的引用。

小贴士

如果你打算检查就地数学运算符,请确保将父模拟对象放在一个不会被重新分配的变量中。此外,你应该确保你的模拟就地运算符返回操作的结果,即使这意味着return self.return_value,否则 Python 将把None分配给左边的变量。

在地运算符还有另一个你应该记住的详细工作方式:

>>> mock = MagicMock()
>>> x = mock
>>> x += 5
>>> x
<MagicMock name='mock.__iadd__()' id='139845830142216'>
>>> x += 10
>>> x
<MagicMock name='mock.__iadd__().__iadd__()' id='139845830154168'>
>>> mock.mock_calls
[call.__iadd__(5), call.__iadd__().__iadd__(10)]

因为操作的结果被分配给了原始变量,一系列的就地数学运算构建了一个子模拟对象链。如果你这么想,那是对的,但人们一开始很少期望是这样。

属性和描述符的模拟对象

基本的Mock对象在模拟某些事物方面并不擅长:描述符

描述符是允许你干扰正常变量访问机制的对象。最常用的描述符是由 Python 的内置函数property创建的,它简单地允许你编写函数来控制获取、设置和删除变量。

要模拟属性(或其他描述符),创建一个unittest.mock.PropertyMock实例并将其分配给属性名称。唯一的复杂性是你不能将描述符分配给对象实例;你必须将其分配给对象的类型,因为描述符是在类型中查找的,而不是首先检查实例。

幸运的是,用模拟对象做这件事并不难:

>>> from unittest.mock import PropertyMock
>>> mock = Mock()
>>> prop = PropertyMock()
>>> type(mock).p = prop
>>> mock.p
<MagicMock name='mock()' id='139845830215328'>
>>> mock.mock_calls
[]
>>> prop.mock_calls
[call()]
>>> mock.p = 6
>>> prop.mock_calls
[call(), call(6)]

在这里需要注意的事情是,该属性不是名为 mock 的对象的子属性。正因为如此,我们必须保留它自己的变量,否则我们就无法访问其历史记录。

PropertyMock对象将变量查找记录为不带参数的调用,将变量赋值记录为带有新值的参数的调用。

小贴士

如果你确实需要在模拟对象的历史记录中记录变量访问,可以使用PropertyMock对象。通常你不需要这样做,但这个选项是存在的。

即使你是通过将属性分配给类型的属性来设置属性的,你也不必担心你的PropertyMock对象会溢出到其他测试中。你创建的每个Mock都有自己的类型对象,尽管它们都声称属于同一个类:

>>> type(Mock()) is type(Mock())
False

多亏了这个特性,你对模拟对象类型对象所做的任何更改都是针对该特定模拟对象的。

模拟文件对象

你可能会偶尔需要用模拟对象替换文件对象。unittest.mock库通过提供mock_open来帮助你,这是一个伪造打开函数的工厂。这些函数具有与真实打开函数相同的接口,但它们返回一个配置为假装是打开文件对象的模拟对象。

这听起来比实际情况要复杂。请亲自看看:

>>> from unittest.mock import mock_open
>>> open = mock_open(read_data = 'moose')
>>> with open('/fake/file/path.txt', 'r') as f:
...   print(f.read())
...
moose

如果您将字符串值传递给 read_data 参数,最终创建的模拟文件对象将在其读取方法被调用时使用该值作为数据源。截至 Python 3.4.0,read_data 只支持字符串对象,不支持字节。

如果您没有传递 read_dataread 方法调用将返回一个空字符串。

之前代码的问题在于它使真实打开函数不可访问,并留下一个模拟对象,其他测试可能会遇到它。请继续阅读以了解如何解决这些问题。

用模拟对象替换真实代码

unittest.mock 库提供了一个非常棒的临时用模拟对象替换对象的工具,并在我们的测试完成后撤销更改。这个工具就是 unittest.mock.patch

那个 patch 可以以多种不同的方式使用:它作为一个上下文管理器、一个函数装饰器和一个类装饰器;此外,它还可以创建一个用于替换的模拟对象,或者使用您指定的替换对象。还有一些其他可选参数可以进一步调整 patch 的行为。

基本用法很简单:

>>> from unittest.mock import patch, mock_open
>>> with patch('builtins.open', mock_open(read_data = 'moose')) as mock:
...    with open('/fake/file.txt', 'r') as f:
...       print(f.read())
...
moose
>>> open
<built-in function open>

如您所见,patch 将由 mock_open 创建的模拟打开函数覆盖在真实打开函数之上;然后,当我们离开上下文时,它会自动为我们替换原始函数。

patch 的第一个参数是唯一必需的参数。它是一个描述要替换的对象的绝对路径的字符串。路径可以包含任意数量的包和子包名称,但必须包括模块名称和模块中被替换的对象的名称。如果路径不正确,patch 将根据路径的具体错误抛出 ImportErrorTypeErrorAttributeError

如果您不想担心创建一个模拟对象作为替换,您可以直接省略该参数:

>>> import io
>>> with patch('io.BytesIO'):
...    x = io.BytesIO(b'ascii data')
...    io.BytesIO.mock_calls
[call(b'ascii data')]

如果您没有告诉 patch 使用什么作为替换对象,patch 函数将为您创建一个新的 MagicMock。这通常工作得很好,但您可以通过传递新的参数(也是本节第一个示例中的第二个参数)来指定替换对象应该是特定的对象;或者您可以通过传递 new_callable 参数来让 patch 使用该参数的值来创建替换对象。

我们也可以通过传递 autospec=True 来强制 patch 使用 create_autospec 创建替换对象:

>>> with patch('io.BytesIO', autospec = True):
...    io.BytesIO.melvin
Traceback (most recent call last):
 File "<stdin>", line 2, in <module>
 File "/usr/lib64/python3.4/unittest/mock.py", line 557, in __getattr__
 raise AttributeError("Mock object has no attribute %r" % name)
AttributeError: Mock object has no attribute 'melvin'

通常,patch 函数会拒绝替换不存在的对象;但是,如果您传递 create=True,它将愉快地在您喜欢的任何地方放置一个模拟对象。当然,这与 autospec=True 不兼容。

patch 函数涵盖了最常见的用例。还有一些相关的函数处理较少见但仍很有用的用例。

patch.object函数与patch做的是同样的事情,只不过它接受一个对象和一个属性名称作为其前两个参数,而不是路径字符串。有时这比找出对象的路径更方便。许多对象甚至没有有效的路径(例如,仅存在于函数局部作用域中的对象),尽管修补它们的需求比您想象的要少。

patch.dict函数临时将一个或多个对象放入字典中的特定键下。第一个参数是目标字典;第二个参数是从中获取键值对以放入目标字典的字典。如果您传递clear=True,则在插入新值之前将清空目标字典。请注意,patch.dict不会为您创建替换值。如果您想使用它们,您需要自己创建模拟对象。

模拟对象在行动

那是很多理论与不切实际的例子交织在一起。让我们回顾一下我们已经学到的内容,并将其应用到前几章的测试中,以便更真实地了解这些工具如何帮助我们。

更好的 PID 测试

PID 测试主要受到必须进行大量额外工作来修补和取消修补time.time的影响,并且在打破对构造函数的依赖方面有一些困难。

修补 time.time

使用patch,我们可以消除处理time.time的许多重复性;这意味着我们不太可能在某个地方犯错误,并节省了我们花费时间在某种程度上既无聊又令人烦恼的事情上。所有的测试都可以从类似的变化中受益:

>>> from unittest.mock import Mock, patch
>>> with patch('time.time', Mock(side_effect = [1.0, 2.0, 3.0, 4.0, 5.0])):
...    import pid
...    controller = pid.PID(P = 0.5, I = 0.5, D = 0.5, setpoint = 0,
...                         initial = 12)
...    assert controller.gains == (0.5, 0.5, 0.5)
...    assert controller.setpoint == [0.0]
...    assert controller.previous_time == 1.0
...    assert controller.previous_error == -12.0
...    assert controller.integrated_error == 0.0

除了使用patch来处理time.time之外,这个测试已经发生了变化。我们现在可以使用assert来检查事物是否正确,而不是让 doctest 直接比较值。这两种方法几乎没有区别,只不过我们可以将assert语句放在patch管理的上下文中。

与构造函数解耦

使用模拟对象,我们最终可以将 PID 方法的测试与构造函数分开,这样构造函数中的错误就不会影响结果:

>>> with patch('time.time', Mock(side_effect = [2.0, 3.0, 4.0, 5.0])):
...    pid = imp.reload(pid)
...    mock = Mock()
...    mock.gains = (0.5, 0.5, 0.5)
...    mock.setpoint = [0.0]
...    mock.previous_time = 1.0
...    mock.previous_error = -12.0
...    mock.integrated_error = 0.0
...    assert pid.PID.calculate_response(mock, 6) == -3.0
...    assert pid.PID.calculate_response(mock, 3) == -4.5
...    assert pid.PID.calculate_response(mock, -1.5) == -0.75
...    assert pid.PID.calculate_response(mock, -2.25) == -1.125

我们在这里所做的是设置一个具有适当属性的模拟对象,并将其作为 self 参数传递给calculate_response。我们可以这样做,因为我们根本就没有创建 PID 实例。相反,我们在类内部查找方法的函数并直接调用它,这使得我们可以传递任何我们想要的作为 self 参数,而不是让 Python 的自动机制来处理它。

从不调用构造函数意味着我们对它可能包含的任何错误免疫,并保证了对象状态正是我们在calculate_response测试中期望的。

摘要

在本章中,我们了解了一组专门模仿其他类、对象、方法和函数的对象家族。我们看到了如何配置这些对象以处理它们默认行为不足的边缘情况,并且我们学习了如何检查这些模拟对象所保留的活动日志,以便我们可以决定这些对象是否被正确使用。

在下一章中,我们将探讨 Python 的 unittest 包,这是一个比 doctest 更为结构化的测试框架,它在与人沟通方面不如 doctest 有用,但更能处理大规模测试的复杂性。

第五章. 使用 unittest 进行结构化测试

doctest工具非常灵活且极易使用,但我们已经注意到,在编写有纪律的测试时,它似乎有些不足。但这并不意味着不可能;我们已经看到我们可以在doctest中编写行为良好的、隔离的测试。问题是doctest并没有为我们做这些工作。幸运的是,我们手头还有另一个测试工具,这个工具要求我们的测试有更多的结构,并提供更多的支持:unittest

unittest模块是基于单元测试的要求设计的,但它实际上并不局限于这一点。您可以使用单元测试进行集成和系统测试。

doctest一样,unittest是 Python 标准库的一部分;因此,如果您有 Python,您就有单元测试。

在本章中,我们将涵盖以下主题:

  • unittest框架内编写测试

  • 运行我们的新测试

  • 看看使unittest成为大型测试套件良好选择的功能

基础知识

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

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

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

在与pid.py相同的目录下创建一个名为test_pid.py的新文件。请注意,这是一个.py文件:unittest测试是纯 Python 源代码,而不是包含源代码的纯文本。这意味着从文档的角度来看,测试将不那么有用,但这也带来了其他好处。

将以下代码插入您新创建的test_pid.py文件中:

from unittest import TestCase, main
from unittest.mock import Mock, patch

import pid

class test_pid_constructor(TestCase):
    def test_constructor_with_when_parameter(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)

有时候,人们会争论,单元测试不应该包含超过一个断言。这种想法是,每个单元测试应该只测试一件事情,以进一步缩小测试失败时的问题范围。这是一个很好的观点,但在我看来,不应该过分狂热。在像前面代码那样的情况下,将每个断言拆分到自己的测试函数中,并不会比我们这样得到更多的信息性错误消息;这只会增加我们的开销。

我的经验法则是,一个测试函数可以有任意数量的简单断言,但最多只能有一个非简单断言:

    @patch('pid.time', Mock(side_effect = [1.0]))
    def test_constructor_without_when_parameter(self):
        controller = pid.PID(P = 0.5, I = 0.5, D = 0.5,
                             setpoint = 0, initial = 12)

        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)

class test_pid_calculate_response(TestCase):
    def test_with_when_parameter(self):
        mock = Mock()
        mock.gains = (0.5, 0.5, 0.5)
        mock.setpoint = [0.0]
        mock.previous_time = 1.0
        mock.previous_error = -12.0
        mock.integrated_error = 0.0

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

    @patch('pid.time', Mock(side_effect = [2.0, 3.0, 4.0, 5.0]))
    def test_without_when_parameter(self):
        mock = Mock()
        mock.gains = (0.5, 0.5, 0.5)
        mock.setpoint = [0.0]
        mock.previous_time = 1.0
        mock.previous_error = -12.0
        mock.integrated_error = 0.0

        self.assertEqual(pid.PID.calculate_response(mock, 6), -3)
        self.assertEqual(pid.PID.calculate_response(mock, 3), -4.5)
        self.assertEqual(pid.PID.calculate_response(mock, -1.5), -0.75)
        self.assertEqual(pid.PID.calculate_response(mock, -2.25), -1.125)

现在,通过在命令行中输入以下内容来运行测试:

python3 -m unittest discover

您应该看到类似以下内容的输出:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_05_01.jpg

那么,我们做了什么呢?有几个需要注意的地方:

  • 首先,所有测试都是继承自unittest.TestCase的类的自己的方法。

  • 测试的命名格式为test_<something>,其中<something>是一个描述,帮助你(以及共享代码的其他人)记住测试实际上在检查什么。这很重要,因为unittest(以及几个其他测试工具)使用名称来区分测试和非测试方法。一般来说,你的测试方法名称和测试模块文件名应该以 test 开头。

  • 因为每个测试都是一个方法,所以每个测试自然在自己的变量作用域中运行。在这里,我们通过保持测试的隔离性获得了很大的优势。

  • 我们从TestCase继承了assert<Something>方法。这些方法为我们提供了更灵活的检查值是否匹配的方式,并提供了比 Python 基本assert语句更有用的错误报告。

  • 我们使用unittest.mock.patch作为方法装饰器。在第四章中,我们将其用作上下文管理器。无论是哪种方式,它都做了同样的事情:用一个模拟对象替换一个对象,然后将其放回原处。当用作装饰器时,替换发生在方法运行之前,而原始对象在方法完成后被放回。这正是我们测试是一个方法时所需要的,所以我们将以这种方式做很多。

  • 我们没有覆盖time.time,而是覆盖了over pid.time。这是因为我们在这里不是为每个测试重新导入pid模块。pid模块包含from time import time,这意味着当它首次加载时,time函数会直接引用到pid模块的作用域中。从那时起,改变time.timepid.time没有任何影响,除非我们改变它并重新导入pid模块。为了避免所有这些麻烦,我们直接覆盖了pid.time

  • 我们没有告诉unittest要运行哪些测试。相反,我们让它自己发现测试,并且它自己找到了测试并自动运行它们。这通常效果很好,可以节省精力。我们将在第六章中查看一个更复杂的测试发现和执行工具。

  • unittest模块为每个成功的测试打印出一个点。对于失败的测试或引发意外异常的测试,它会提供更多信息。

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

每个测试方法体现了一个单一单元的单一测试。这为我们提供了一个方便的方式来组织测试,将相关的测试组合到同一个类中,以便更容易找到。你可能已经注意到我们在示例中使用了两个测试类。这在当前情况下是为了组织目的,尽管也有很好的实际理由将测试分离到多个类中。我们很快就会谈到这一点。

将每个测试放入它自己的方法意味着每个测试都在一个独立的命名空间中执行,这使得相对于 doctest 风格的测试,更容易保持 unittest 风格的测试之间不相互干扰。这也意味着 unittest 知道你的测试文件中有多少个单元测试,而不是简单地知道有多少个表达式(你可能已经注意到 doctest 将每行 >>> 视为一个单独的测试)。最后,将每个测试放入它自己的方法意味着每个测试都有一个名称,这可以是一个非常有价值的特性。当你运行 unittest 时,它会在错误报告中包括任何失败的测试的名称。

unittest 中的测试并不直接关心任何不是 TestCase 断言方法调用一部分的内容。这意味着我们不必担心我们调用的任何函数的返回值或我们使用的任何表达式的结果,除非它们对测试很重要。这也意味着我们需要记住为测试的每个方面编写一个断言。我们很快就会介绍 TestCase 的各种断言方法。

断言

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

assertTrue 方法

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

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

例如,考虑以下包含两个保证会失败的测试的测试代码:

from unittest import TestCase

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

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

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

运行测试并查看发生了什么(同时注意测试的执行顺序并不一定与我们编写的顺序相同;测试之间完全独立,所以这是可以接受的,对吧?)。

两个测试都如预期那样失败了,但使用 assertEqual 的测试告诉我们:

AssertionError: 1 != 2

另一个说法是:

AssertionError: False is not true

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

assertFalse方法

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

assertEqual方法

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

assertNotEqual方法

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

assertAlmostEqual方法

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

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

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

from unittest import TestCase

class floating_point_problems(TestCase):
    def test_square_root_of_seven_squared_incorrectly(self):
        self.assertEqual((7.0 ** 0.5) ** 2.0, 7.0)

    def test_square_root_of_seven_squared(self):
        self.assertAlmostEqual((7.0 ** 0.5) ** 2.0, 7.0)

test_square_root_of_seven_squared_incorrectly方法检查https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_05_02.jpg,这在现实中是正确的。然而,在计算机可用的更专业的数系中,取 7 的平方根然后平方并不完全回到 7,因此这个测试将失败。我们稍后会更详细地探讨这个问题。

test_square_root_of_seven_squared方法检查https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_05_03.jpg,即使是计算机也会发现这是正确的,所以这个测试应该通过。

不幸的是,浮点数(计算机用于表示实数的数字表示)并不精确,因为实数线上的大多数数字不能用有限且不重复的数字序列来表示,更不用说只有 64 位了。因此,从上一个例子中评估数学表达式得到的结果并不完全是七。不过,这对于政府工作来说已经足够好了——或者实际上任何其他类型的工作也是如此——所以我们不想让我们的测试对那个微小的差异斤斤计较。正因为如此,当我们比较浮点数时,我们应该习惯性地使用 assertAlmostEqualassertNotAlmostEqual

注意

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

assertNotAlmostEqual 方法

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

assertIs 和 assertIsNot 方法

assertIsassertIsNot 方法与 Python 的 is 运算符的关系与 assertEqualassertNotEqual 与 Python 的 == 运算符的关系相同。这意味着它们检查两个操作数是否(或不是)完全相同的对象。

assertIsNone 和 assertIsNotNone 方法

assertIsNone 和 assertIsNotNone 方法类似于 assertIsassertIsNot,除了它们只接受一个参数,它们总是将其与 None 进行比较,而不是接受两个参数并将它们相互比较。

assertIn 和 assertNotIn 方法

assertIn 方法用于检查容器对象,如字典、元组、列表和集合。如果第一个参数包含在第二个参数中,则断言通过。如果不包含,则断言失败。assertNotIn 方法执行相反的检查。

assertIsInstance 和 assertNotIsInstance 方法

assertIsInstance 方法检查作为第一个参数传递的对象是否是作为第二个参数传递的类的实例。assertNotIsInstance 方法执行相反的检查,确保对象不是该类的实例。

assertRaises 方法

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

assertRaises 方法检查当传递指定的参数集时,可调用对象是否引发指定的异常。

注意

可调用对象是一个函数、一个方法、一个类或任何具有 __call__ 方法的任意类型的对象。

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

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

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

from unittest import TestCase

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

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

AssertionError: ValueError not raised by int

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

fail方法

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

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

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

from unittest import TestCase

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

小贴士

如果在测试中某个特定的比较被反复使用,你可以为那个比较编写自己的assert函数,使用fail来报告错误,就像我们在前面的例子中所做的那样。

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

确保你理解了

看看下面的doctest。你能想出等效的unittest会是什么样子吗?

>>> try:
...     int('123')
... except ValueError:
...     pass
... else:
...     print('Expected exception was not raised')

doctest 代码尝试将字符串转换为整数;如果这种转换不会引发 ValueError,它将报告一个错误。在 unittest 中,这看起来是这样的:

class test_exceptions(TestCase):
    def test_ValueError(self):
        self.assertRaises(ValueError, int, '123')

你如何在 unittest 中检查两个浮点数是否相等?你应该使用 assertAlmostEqual 方法,这样就不会被浮点数的不精确性所困扰。

你会在什么情况下选择使用 assertTrue?又或者 fail?如果你没有更专业的断言满足你的需求,你会使用 assertTrue。如果你需要在测试成功或失败时拥有最大控制权,你会使用 fail

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

在进行这个过程中,思考一下 unittestdoctest 对你翻译的每个测试的相对优点。这两个系统有不同的优势,因此对于不同的情况,每个系统都将是更合适的选择。在什么情况下 doctest 是更好的选择,而在什么情况下是 unittest

测试固定装置

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

示例 – 测试数据库支持的单元

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

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

unittest 通过提供 TestCase 类的 setUptearDown 方法来帮助我们完成这项工作。这些方法存在是为了让我们可以重写,默认版本不执行任何操作。

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

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

注意

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

我们首先导入所需的模块并介绍我们的TestCase子类:

from unittest import TestCase
from sqlite3 import connect, PARSE_DECLTYPES
from datetime import date
from employees import Employees

class test_employees(TestCase):

我们需要一个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

我们需要一个tearDown方法来撤销setUp方法所做的任何操作,以便每个测试都可以在一个未受干扰的环境中运行。由于数据库仅存在于内存中,我们只需关闭连接,它就会消失。在其他场景中,tearDown方法可能要复杂得多:

    def tearDown(self):
        self.connection.close()

最后,我们需要测试本身:

    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)

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

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

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

我们本可以使用模拟对象来处理数据库连接,这种方法并没有什么问题,只是在这种情况下,对我们来说可能需要更多的努力。有时模拟对象是完成工作的完美工具,有时测试固定装置可以节省精力;有时你需要两者结合才能轻松完成任务。

摘要

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

具体来说,我们介绍了如何使用unittest来表达你从doctest中已经熟悉的概念;unittest和 doctest 之间的差异和相似之处;如何使用测试固定装置将你的测试嵌入到一个受控和临时的环境中;以及如何使用unittest.mock补丁来装饰测试方法,以进一步控制测试执行时的环境。

在下一章中,我们将探讨一个名为 Nose 的工具,它能够在同一测试运行中找到并运行doctest测试、unittest测试和临时测试,并为你提供统一的测试报告。

第六章:使用 Nose 运行你的测试

在上一章中,我们看到了unittest发现工具在不明确告知它们位置的情况下找到我们的测试。与doctest让我们明确告诉它应该在哪里找到要运行的测试相比,这非常方便,尤其是当我们谈论一个包含许多位置的测试的大型源树时。

Nose 是一个基于这个想法的工具。它能够在整个源树中找到unittest测试、doctest测试和临时测试,并运行它们。然后,它会向你展示一个关于测试成功和失败的统一报告。换句话说,Nose 让你可以为任何给定的测试选择正确的测试工具,简单方便地集成它们。

Nose 还提供了一些新的测试功能,例如模块级别的设置和一些新的断言函数。

安装 Nose

鼻子(Nose)不是 Python 标准库的一部分,这意味着你需要自己安装它。你可以使用一条命令来安装 Nose:

python3 -m pip install --user nose

小贴士

如果命令报告找不到名为pip的模块,你需要运行以下命令来安装pip模块:

python3 -m ensurepip --user

ensurepip模块自 Python 3.4 起成为标准库的一部分,因此你可以信赖它总是可用的。尽管如此,你可能不需要它,因为尽管pip不是标准库的一部分,但它包含在 Python 发行版中。

之前命令中的--user命令行开关告诉工具将其安装到你的个人 Python 包文件夹中。如果你省略了这个命令,它将尝试为所有用户安装 Nose。

就这些了。Nose 已经准备好使用了。

组织测试

好吧,我们已经安装了 Nose,那么它有什么好处呢?Nose 会遍历目录结构,找到测试文件,整理出它们包含的测试,运行测试,并将结果报告给你。这是一项你每次想要运行测试时都不必做的很多工作——你应该经常运行测试。

Nose 根据文件名识别测试文件。任何名称包含testTest,无论是位于开头还是位于任何字符_(下划线)、.(点)或(破折号)之后,都被识别为可能包含测试的地方。Python 源文件和包目录也是如此。任何可能包含测试的文件都会被检查是否存在unittest TestCases以及任何名称表明它们是测试的函数。Nose 还可以找到并执行嵌入在文档字符串中或单独编写的doctest测试。默认情况下,它不会寻找doctest测试,除非我们告诉它。我们很快就会看到如何更改默认设置。

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

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_06_01.jpg

这种结构只是一个建议…这是为了你的便利,而不是为了 Nose。如果你觉得不同的结构会使事情更容易,那么请随意使用它。

测试组织的一个例子

我们将从前几章中选取一些测试,并将它们组织成一个目录树。然后,我们将使用 Nose 运行它们。

第一步是创建一个将存放我们的代码和测试的目录。你可以随意命名,但在这里我将称之为 project

将前几章中的 pid.pyavl_tree.pyemployees.py 文件复制到 project 目录中。同时,将 第二章 中的 test.py 文件也放入这里,并将其重命名为 inline_doctest.py。我们希望它被视为源文件,而不是测试文件,这样你就可以看到 Nose 如何处理带有 doctest 的源文件。放置在 project 目录中的模块和包,无论测试在树中的位置如何,都将可用于测试。

创建一个名为 test_chapter2 的子目录,位于 project 目录下,并将 第二章 中的 AVL.txttest.txt 文件,使用 doctest,放入其中。

project 目录下创建一个名为 test_chapter3 的子目录,并将 PID.txt 放入其中。

project 目录下创建一个名为 test_chapter5 的子目录,并将 第五章 中的所有 test_* 模块,使用 unittest 进行结构化测试,放入其中。

现在,我们已经准备好使用以下代码运行我们的测试:

python3 -m nose --with-doctest --doctest-extension=txt -v

小贴士

如果你不想使用 -v 选项,也可以省略。这只是为了告诉 Nose 提供更详细的报告。

所有测试都应该运行。我们预计会看到一些失败,因为前几章中的一些测试是为了说明目的而故意设计的。然而,以下截图显示了一个我们需要考虑的失败:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_06_02.jpg

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

>>> import time
>>> real_time = time.time
>>> time.time = (float(x) for x in range(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

我们模拟了time.time,确实如此(尽管最好使用unittest.mockpatch函数)。为什么pid.py中的from time import time获取的是错误的时间函数(也就是说,真实的时间)呢?如果在这个测试运行之前pid.py已经被导入,那么from time import time就已经在模拟被放置之前运行了,它将永远不会知道有模拟的存在。那么,是其他什么在pid.txt导入它之前导入了pid.py吗?实际上是这样的:Nose 在扫描要执行测试时导入了它。如果我们使用 Nose,我们不能指望我们的import语句实际上是第一个导入任何给定模块的。不过,我们可以通过使用patch来替换测试代码中找到的time函数来轻松解决这个问题:

>>> from unittest.mock import Mock, patch
>>> import pid
>>> with patch('pid.time', Mock(side_effect = [1.0, 2.0, 3.0])):
...    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

小贴士

注意,我们在这里只查看文件中的第一个测试。还有一个测试,虽然它通过了,但以同样的方式编写会更好。你能找到那个测试并改进它吗?

不要被弄混:我们切换到使用unittest.mock进行这个测试,并不是因为它解决了问题,而是因为它是一个更好的模拟对象的工具。真正的解决方案是我们从替换time.time切换到替换pid.time。在pid.py中,除了import行之外,没有其他地方引用time。这意味着我们需要模拟的是pid.time,而且一直都是。pid.timetime.time的另一个名称,这是无关紧要的;我们应该模拟找到对象的地方,而不是它来自哪里。

现在,当我们再次运行测试时,唯一的失败是预期的。你的总结报告(因为我们把-v传递给命令行上的 Nose)应该看起来像这样:

https://github.com/OpenDocCN/freelearn-python-zh/raw/master/docs/lrn-py-tst/img/3211OS_06_03.jpg

我们刚刚看到隐藏的假设如何破坏测试,就像它们可以破坏被测试的代码一样。到目前为止,我们一直假设,当我们的某个测试导入一个模块时,这是该模块第一次被导入。一些测试依赖于这个假设来用模拟对象替换库对象。现在,我们正在处理运行许多聚合在一起的测试,没有保证的执行顺序,这个假设是不可靠的。更不用说,我们遇到麻烦的模块实际上必须被导入以在运行任何测试之前搜索它。通过快速切换受影响的测试以使用更好的方法,我们就可以继续进行。

因此,我们只使用一个命令运行了所有这些测试,我们可以将测试分散到尽可能多的目录、源文件和文档中,以保持一切井然有序。这真是太好了。我们正在接近测试在现实世界中变得有用的地步。

我们可以将我们的测试存储在单独且井然有序的目录结构中,并使用单个简单快捷的命令运行它们。我们还可以通过传递包含我们想要运行的测试的文件名、模块名或包含测试的目录作为命令行参数,轻松地运行测试的子集。

简化 Nose 的命令行

我们之前使用的python3 -m nose命令并不难理解,但如果我们要一直输入它,它就比我们希望的更长。而不是以下命令:

python3 -m nose --with-doctest --doctest-extension=txt -v

我们真的更希望只使用以下命令:

python3 -m nose

或者,甚至更简单:

nosetests

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

[nosetests]
with-doctest=1
doctest-extension=txtIf you're a Windows user, you might not be sure what the phrase "home directory" is supposed to denote in this context. As far as Python is concerned, your home directory is defined by your environment variables. If HOME is defined, that's your home directory. Otherwise, if USERPROFILE is defined (it usually is, pointing at C:\Documents and Settings\USERNAME) then that is considered to be your home directory. Otherwise, the directory described by HOMEDRIVE and HOMEPATH (often C:\)is your home directory.

在配置文件中设置选项可以处理所有多余的命令行参数。从现在开始,每次你运行 Nose 时,它都会假设这些选项,除非你明确告知它否则。你不再需要在命令行上输入它们。你可以为 Nose 可以接受的任何命令行选项使用同样的技巧。

对于第二次改进,Nose 在安装时会安装一个名为nosetests的脚本。输入nosetests与输入python3 -m nose完全相同,但你可能需要将包含nosetests的目录添加到你的PATH环境变量中,它才能正常工作。在示例中,我们将继续使用python3 -m nose

自定义 Nose 的测试搜索

我们之前说过,Nose 使用目录、模块和函数的名称来告知其搜索测试。以testTest开头,或包含一个_.后跟testTest的目录和模块将被包括在搜索中,除了 Nose 决定应该搜索的其他任何地方。这是默认设置,但这并不是全部。

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

例如,运行以下命令:

python3 -m nose --include="(?:^[Dd]oc)"

现在,Nose 除了使用单词test查找名称外,还会查找以docDoc开头的名称。这意味着你可以将包含你的doctest文件的目录命名为docsDocumentationdoctests等,Nose 仍然会找到并运行这些测试。如果你经常使用此选项,你几乎肯定会想将其添加到你的配置文件中,如前所述。

注意

正则表达式的完整语法和使用是一个主题本身,并且已经成为许多书籍的主题;但你可以从 Python 文档中找到你需要做的所有事情,网址为docs.python.org/3/library/re.html

检查你的理解

通过运行python3 -m nose --processes=4,Nose 可以同时启动四个测试进程,如果你在一个四核系统上运行测试,这将是一个很大的优势。你将如何让 Nose 始终启动四个测试进程,而无需在命令行中指定?答案是只需在你的 Nose 配置文件中将processes=4放入即可。

如果你的某些测试存储在一个名为specs的目录中,你将如何告诉 Nose 它应该在该目录中搜索测试?你需要将--include="specs"添加到 Nose 命令行。

以下哪个会被 Nose 默认识别为可能包含UnitTestsunit_testsTestFilestest_filesdoctests测试?答案是unit_testsTestFilestest_files会被 Nose 的默认配置识别。

练习 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.

鼻子测试和 doctest 测试

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

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

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

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

Nose 和 unittest 测试

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

模块固定装置练习

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

  1. 创建一个名为tests的目录。

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

    from unittest import TestCase
    from unittest.mock import patch, Mock
    from datetime import date
    
    fake_date = Mock()
    fake_date.today = Mock(return_value = date(year = 2014,
                                               month = 6,
                                               day = 12))
    
    patch_date = patch('module_fixture_tests.date', fake_date)
    
    def setup():
        patch_date.start()
    
    def teardown():
        patch_date.stop()
    
    class first_tests(TestCase):
        def test_year(self):
            self.assertEqual(date.today().year, 2014)
    
        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(), '2014-06-12')
    
  3. 注意,在这个模块中有两个TestCase类。使用纯unittest,我们不得不在每个这些类中重复固定装置代码。Nose 允许我们只写一次,然后在两个地方使用它。

  4. 前往包含测试目录的目录,并输入python -m nose来运行测试。

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

你可能已经注意到了在之前的示例中又一种使用unittest.mock.patch的方式。除了可以作为装饰器或上下文管理器使用外,你还可以将patch函数用作构造函数,并对其返回的对象调用startstop方法。在所有可以使用patch函数的方式中,在大多数情况下,你应该避免使用这种方法,因为这要求你务必记得调用stop函数。前面的代码如果使用patch_date作为每个TestCase类的类装饰器会更好,除非这里的目的是为了展示模块级固定装置的样子。

通常,而不是创建模拟对象,setupteardown会做一些诸如处理、创建和销毁临时文件等事情。

我们可以通过使用一层环绕整个测试模块的测试夹具,而不是单个测试方法,来节省一些时间和精力。通过这样做,我们避免了在每个测试类内部重复夹具代码;但这也带来了一定的代价。setupteardown函数不会在每次测试前后运行,就像正常的测试夹具一样。相反,模块中的所有测试都在单个模块级别的 setup/teardown 对之间进行,这意味着如果测试执行了影响由setup函数创建的环境的操作,那么在下一个测试运行之前,这些操作不会被撤销。换句话说,测试的隔离性在模块级别的夹具创建的环境方面不能得到保证。

包夹具练习

现在,我们将创建一个夹具,它将围绕整个包中的所有测试模块。执行以下步骤:

  1. 在我们上一个练习部分中创建的tests目录中添加一个名为__init__.py的新文件。(这是两个下划线,单词init和另外两个下划线)。这个文件的存在告诉 Python 该目录是一个包。

  2. module_fixture_tests.py中,更改:

    patch_date = patch('module_fixture_tests.date', fake_date)
    

    以下内容:

    patch_date = patch('tests.module_fixture_tests.date', fake_date)
    
  3. tests目录中的__init__.py文件内放置以下代码:

    from os import unlink
    
    def setup():
        with open('test.tmp', 'w') as f:
            f.write('This is a test file.')
    
    def teardown():
        unlink('test.tmp')
    

    注意

    通常,__init__.py文件是完全空的,但它们是包对象的规范来源;因此,这就是 Nose 寻找包级别夹具的地方。

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

    from unittest import TestCase
    from glob import glob
    
    class check_file_exists(TestCase):
        def test_glob(self):
            self.assertIn('test.tmp', glob('*.tmp'))
    
  5. 继续运行测试。你不会看到很多输出,但这只是意味着测试通过了。注意,test_glob函数只有在test.tmp存在的情况下才能成功。由于这个文件是在包设置中创建并在包清理时销毁(并且它不再存在),我们知道设置是在测试之前运行的,清理是在测试之后运行的。如果我们向module_fixture_tests.py添加一个依赖于test.tmp的测试,它们也会通过,因为setup函数在包中的任何测试之前调用,teardown在包中的每个测试运行之后调用。

    小贴士

    glob模块提供了将命令行风格的通配符扩展为文件名列表的能力。glob.glob函数是几个 globbing 函数之一。

我们与另一层测试夹具一起工作,这次是围绕tests目录中的所有测试模块。从我们刚刚编写的代码中可以看出,包级别测试夹具创建的环境在包中每个模块的每个测试中都是可用的。

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

注意

为什么我们在添加包级固定装置时将 'module_fixture_tests.date' 改为 'tests.module_fixture_tests.date' 呢?因为当我们向 tests 目录添加 __init__.py 时,在 Python 看来,我们将其目录变成了一个 Python 包。作为一个 Python 包,它的名称是其内部任何变量的绝对名称的一部分,这间接包括我们导入的 date 类。我们必须传递一个绝对变量名给 patch,因此我们必须从包含的包名称开始。

Nose 和临时测试

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

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

让我们在 tests 目录中创建一个新的测试文件,命名为 nose_specific_tests.py。在文件内部,放置以下代码:

import sys
from sqlite3 import connect
from imp import reload

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)

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

现在运行 Nose 并不会打印出很多内容,但测试已运行且未失败的事实告诉我们很多。

grouped_tests 类包含一个测试固定装置(setupteardown 方法)和两个测试;但它不是一个 unittestTestCase 类。Nose 通过其名称符合 Nose 在检查模块名称以查找测试模块时寻找的相同模式,将其识别为测试类。然后它遍历该类以查找测试固定装置和任何测试方法,并相应地运行它们。

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

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

我们通过将 setupteardown 属性设置为定义为此目的的一对函数,将测试固定装置附加到 standalone_test 上。像往常一样,setup 函数在测试函数之前运行,teardown 函数在测试函数之后运行。

摘要

在本章中,我们关于 Nose 测试元框架学到了很多。具体来说,我们涵盖了 Nose 如何查找包含测试的文件,以及如何调整流程以适应您的组织架构;如何使用 Nose 运行所有的测试,无论它们是doctestunittest还是临时的;Nose 如何通过额外的测试固定支持增强其他框架;以及如何使用 Nose 对独立测试函数和非TestCase测试类的支持。

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

Logo

为武汉地区的开发者提供学习、交流和合作的平台。社区聚集了众多技术爱好者和专业人士,涵盖了多个领域,包括人工智能、大数据、云计算、区块链等。社区定期举办技术分享、培训和活动,为开发者提供更多的学习和交流机会。

更多推荐