Python-测试驱动开发-全-

Python 测试驱动开发(全)

原文:zh.annas-archive.org/md5/e947ec71d6e621ead6e44fb9b4961562

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

现在,组织面临着交付频率不断上升的问题。每几年交付一个新版本的时光已经一去不复返。测试驱动开发过程使个人和团队能够交付既稳健又易于维护的代码。结合 Python 语言的快速开发速度,你就有了一个能够以市场要求的速度交付新功能的组合。

《测试驱动 Python 开发》 覆盖了端到端的单元测试过程,从第一个简单的测试到涉及多个组件和交互的复杂测试。

本书涵盖的内容

第一章, 开始测试驱动开发,是介绍性的一章。它通过设定为什么我们需要 TDD 以及快速开始编写测试来引导你进入 TDD 过程。

第二章, 红-绿-重构 – TDD 循环,更深入地探讨了 TDD 过程,随着我们编写更多的测试来驱动我们示例项目的实现。

第三章, 代码异味和重构,探讨了常见的代码异味类型,我们回到我们的示例项目,清理我们发现的异味。TDD 的一个关键好处是它提供了一个安全网,这样我们就可以进入并清理混乱的代码。

第四章, 使用模拟对象进行交互测试,展示了如何使用模拟来实现我们示例项目中依赖于其他系统的部分。你如何测试依赖于外部子系统的代码?我们在这里通过介绍模拟对象来回答这个问题。

第五章, 处理遗留代码,讨论了我们经常需要清理或添加旧代码的功能,而这些旧代码没有现有的测试。本章通过处理我们示例应用中的一个模块来探讨实现这一点的策略。

第六章, 维护你的测试套件,证明了单元测试也是代码,良好的编码实践同样适用于测试代码。这意味着需要保持它们良好的组织结构并且易于维护。本章涵盖了实现这一点的技术。

第七章, 使用 doctest 进行可执行文档,通过处理我们应用程序的一个模块来介绍 doctest 的使用。

第八章, 使用 nose2 扩展 unittest,让我们了解 nose2,这是一个强大的测试运行器和插件套件,它扩展了 unittest 框架。

第九章, 单元测试模式,涵盖了单元测试的一些其他模式。我们了解到如何加快测试速度,以及如何运行特定的测试子集。我们还探讨了数据驱动测试和模拟模式。

第十章, 提高测试驱动开发的工具,解释了一些流行的第三方工具,帮助我们改进我们的 TDD 实践。其中一些工具,如 pytest 和 trial,是具有独特功能的测试运行器。

附录 A, 练习题答案,包含了本书中提出的练习题的答案。有众多可能的解决方案,每种方案都有其自身的优缺点。

附录 B, 使用较旧的 Python 版本,描述了为了将本书中的技术应用于较旧版本的 Python 所需进行的更改,因为本书是为 Python 3.4 编写的。

您需要本书的内容

您需要以下软件来使用本书:Python 3.4、nose 2 和 lettuce。

本书面向的对象

本书旨在为希望使用测试驱动开发(TDD)原则来创建高效且健壮应用的 Python 开发者编写。为了从本书中获得最佳效果,您应该具备 Python 的开发经验。

习惯用法

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"我们可以通过使用include指令来包含其他上下文。"

代码块设置如下:

import unittest
class StockTest(unittest.TestCase):
    def test_price_of_a_new_stock_class_should_be_None(self):
        stock = Stock("GOOG")
        self.assertIsNone(stock.price)
if __name__ == "__main__":
    unittest.main()

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

class Stock:
    LONG_TERM_TIMESPAN = 10
    SHORT_TERM_TIMESPAN = 5

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

python3 -m unittest discover

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"选择发布 JUnit 测试结果报告复选框,并输入 nose2 单元测试 XML 文件的位置。"

注意

警告或重要注意事项以如下方式显示:

小贴士

小贴士和技巧看起来像这样。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中受益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。

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

客户支持

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

下载示例代码

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

您也可以从github.com/siddhi/test_driven_python获取代码副本。

勘误

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

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

盗版

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

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

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

询问

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

第一章. 测试驱动开发入门

我第一次接触测试驱动开发TDD)是在 2002 年。当时,它还没有像现在这样普及,我记得看到两位开发者先编写了一些测试,然后才实现功能。我觉得这是一种相当奇怪的写代码方式,并且很快就忘记了它。直到 2004 年,当我参与一个具有挑战性的项目时,我才再次想起了 TDD。我们面临的是一团糟的代码,难以测试,每次更改似乎都会产生一系列新的错误。我想,为什么不试试 TDD 看看它效果如何呢? suffice to say,TDD 改变了我对软件开发的观点。我们停止了编写混乱的意大利面代码,开始编写设计更好、更易于维护的代码。回归失败率大幅下降。我上瘾了。

也许,就像我一样,你在项目中遇到了一些挑战,并想知道 TDD 如何帮助你。或者,也许你听到业界很多人对 TDD 大加赞扬,你想知道这一切究竟是怎么回事。也许你一直在阅读关于 TDD 将在不久的将来成为一项必备技能的文章,并想尽快掌握它。无论你的动机是什么,我希望这本书能帮助你实现目标。

TDD 不仅仅是一个库或 API;它是一种不同的软件开发方式。在本书中,我们将讨论如何将这个过程应用于编写 Python 软件。我们很幸运,因为 Python 从一开始就提供了对 TDD 的出色支持。事实上,单元测试自 2001 年 4 月 Python 2.1 版本发布以来一直是 Python 标准库的组成部分。自那时以来,已经添加了许多改进,Python 3.4 版本中包含的最新版本拥有许多令人兴奋的功能,我们将在本书的整个过程中探讨这些功能。

前提条件

本书将使用 Python 3.4。大多数技术同样适用于 Python 2.6+,但可能需要对本书中提供的示例进行一些小的修改,以便它们能够运行。附录 B,使用较旧的 Python 版本列出了这些修改。

本书假设读者具备中级 Python 理解能力。在本书中,我们将使用 Python 语言特性,如 lambda 表达式、装饰器、生成器和属性,并假设读者熟悉它们。虽然我们将在遇到这些特性时简要描述它们,但本书不会深入探讨它们的工作原理,而是选择专注于如何测试此类代码。

注意

注意,如果你在你的系统上只安装了 Python 2.x,那么请访问python.org并下载 Python 3.4 系列的最新版本。对于 Linux 用户,如果你的系统上没有安装 Python 3.4,那么请检查你的发行版的软件包仓库以获取最新版本。如果没有软件包存在,或者你使用的是非标准的或较旧的发行版,那么你可能需要从源代码编译它。有关如何操作的说明可在docs.python.org/devguide/setup.html找到。

由于 TDD 是一种动手编码活动,本书将在整个过程中使用大量的代码片段。我们建议你通过输入代码并亲自运行它来跟随。当你能够看到代码(或没有工作)在你面前运行时,理解代码和概念要容易得多,而不是仅仅阅读本书中的代码。

小贴士

获取代码

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

本书中的所有代码都可以在github.com/siddhi/test_driven_python上找到。你可以选择存储库的特定分支来获取本章开始的代码,并从这个起点开始学习本章。你也可以选择分支上的标签来获取本章末尾的代码,如果你更喜欢跳到代码的末尾。

理解测试驱动开发

在前几段的热潮之后,你可能想知道测试驱动开发究竟是什么,以及它是否是一种复杂的程序,需要很多技能来实现。实际上,测试驱动开发非常简单。下面的流程图显示了过程中的三个步骤。

理解测试驱动开发

让我们更详细地回顾一下前面的流程图。

  • 红色:第一步是编写一个小型单元测试用例。因为我们只编写了测试,还没有编写实现,所以这个测试自然会失败。

  • 绿色:接下来,我们编写实现所需功能的代码。在这个阶段,我们并不是要创建最佳的设计或最易读的代码。我们只想得到一个简单的东西,它能通过测试。

  • 重构:现在测试通过了,我们回头看看代码,看看是否可以改进。这可能涉及改进设计,或使其更易读或更易于维护。我们可以使用迄今为止编写的测试来确保我们在重构步骤中没有破坏任何东西。

  • 当我们进行下一个测试并实现下一个功能片段时,这个循环会重复进行。

熟悉 TDD 的开发者通常每小时会经历多次这个循环,每次实现功能的小步骤。

TDD 与单元测试与集成测试的比较

在我们继续前进之前,让我们短暂地偏离一下,定义一些术语并了解它们之间的区别。很容易在这些术语之间感到困惑,它们在不同的地方经常被赋予不同的含义。

在这个术语的最广泛意义上,单元测试简单地说就是测试单个代码单元,将其与其他可能与之集成的代码隔离开。传统上,单元测试是主要由测试工程师执行的活动。这些工程师会接受开发者给出的代码,并通过一系列测试来验证代码是否工作。由于此代码在集成之前进行了测试,这个过程符合单元测试的定义。传统的单元测试通常是一项手动工作,测试工程师手动执行测试用例,尽管一些团队会更进一步,自动化测试。

集成测试是涉及系统多个单元的测试。目标是检查这些单元是否已正确集成。一个典型的集成测试可能是访问一个网页,填写一个表单,并检查屏幕上是否显示了正确的消息。为了使这个测试通过,UI 必须正确显示表单,输入必须被正确捕获,并且该输入必须传递给任何逻辑处理。这些步骤可能涉及在生成消息和 UI 正确显示之前从数据库中读取和写入。只有当所有这些交互都成功时,集成测试才会通过。如果任何一步失败,集成测试将失败。

到目前为止,一个合理的问题可能是询问我们为什么需要单元测试。为什么不只写集成测试,一个测试就能一次性检查应用中的许多部分呢?原因是集成测试无法精确指出失败的位置。一个失败的集成测试可能存在 UI、逻辑或数据读取或写入过程中的错误。需要大量调查才能找到错误并修复它。相比之下,良好的单元测试会在失败时精确指出是什么出了问题。开发者可以直接找到问题所在并修复错误。

在这个过程中,团队开始转向一个过程,即开发者自己为他们所实现的功能编写测试。这些测试会在开发者完成实现后编写,并有助于验证代码是否按预期工作。这些测试通常是自动化的。这样的过程通常被称为开发者测试开发者单元测试

TDD 通过在开始实现之前编写测试,将开发者测试推进了一步。

  • 开发者测试:开发者编写的任何类型的自动化单元测试,无论是功能实现之前还是之后。

  • 单元测试:由开发者或测试人员对应用程序的特定单元进行的任何类型的测试。这些测试可能是自动化的,也可以手动运行。

  • 集成测试:涉及两个或更多单元一起工作的任何类型的测试。这些测试通常由测试人员执行,但也可以由开发人员执行。这些测试可能是手动或自动的。

如我们所见,单元测试是一个通用术语,而开发者测试是单元测试的一个特定子集,TDD 是开发者测试的一种特定形式。

表面上看,传统的单元测试、开发者测试和 TDD 看起来很相似。它们似乎都是关于为单个代码单元编写测试,只有基于谁编写测试以及测试是在代码编写之前还是之后编写的细微差别。

然而,深入挖掘,差异就出现了。首先,意图大不相同。传统的单元测试和开发者测试都是关于编写测试来验证代码是否按预期工作。另一方面,TDD 的主要焦点实际上并不是测试。在实现相应功能之前简单地编写一个测试,就改变了我们实现功能时的思维方式。生成的代码更易于测试,通常具有简单优雅的设计,并且更易于维护和阅读。这是因为使类易于测试也鼓励良好的设计实践,例如解耦依赖关系和编写小型、模块化的类。

因此,可以说 TDD 完全是关于编写更好的代码,而最终得到一个完全自动化的测试套件只是一个愉快的副作用。

这种意图上的差异在测试类型上表现出来。开发者测试通常会产生大型测试用例,其中相当一部分测试代码涉及测试设置。相比之下,使用 TDD 编写的测试非常小且数量众多。有些人喜欢称它们为微测试,以区分它们与其他开发者测试或传统单元测试。TDD 风格的单元测试还试图非常快地运行,因为它们在开发过程中每隔几分钟就会执行一次。

最后,TDD(测试驱动开发)中编写的测试是推动开发前进的测试,而不一定是覆盖所有可想象场景的测试。例如,一个本应处理文件的函数可能会有处理文件存在或不存在的情况的测试,但可能不会有测试来查看如果文件大小为 1TB 会发生什么。后者可能是测试人员可能会测试的情况,但在 TDD 中除非函数明显预期可以处理这样的文件,否则这种测试是不寻常的。

这真正突出了 TDD 与其他单元测试形式之间的区别。

备注

TDD 是关于编写更好、更干净、更易于维护的代码,而不仅仅是关于测试。

使用 TDD 构建股票警报应用程序

在本书的整个过程中,我们将使用 TDD 构建一个简单的股票警报应用程序。该应用程序将监听来自来源的股票更新。来源可以是任何东西——互联网上的服务器,硬盘上的文件,或其他东西。我们将能够定义规则,当规则匹配时,应用程序会发送给我们电子邮件或短信。

例如,我们可以定义一条规则为“如果 AAPL 股价突破 550 美元,则发送给我一封电子邮件”。一旦定义,应用程序将监控更新,并在规则匹配时发送电子邮件。

编写我们的第一个测试

谈话已经足够。让我们开始我们的应用程序。从检查之前提到的应用程序描述来看,我们似乎需要以下模块:

  • 一种读取股票价格更新的方法,无论是从互联网还是从文件中

  • 管理股票信息以便我们可以处理的方法

  • 定义规则并匹配当前股票信息的方法

  • 当规则匹配时发送电子邮件或短信的方法

根据这些要求,我们将使用以下设计:

编写我们的第一个测试

每个术语如下讨论:

  • 警报:这是应用程序的核心。警报将一个规则映射到一个动作。当规则匹配时,执行该动作。

  • 规则:一个规则包含我们想要检查的条件。当规则匹配时,我们应该收到警报。

  • 动作:这是规则匹配时要执行的动作。这可能只是简单地打印屏幕上的消息,或者在更实际的工作场景中,我们可能会发送电子邮件或短信。

  • 股票股票类跟踪股票的当前价格以及可能的价格历史。当有更新时,它会向警报发送一个事件。然后警报检查是否匹配规则以及是否需要执行任何动作。

  • 事件:当股票更新时,此类用于向警报发送事件。

  • 处理器:处理器从读者那里获取股票更新,并使用最新数据更新股票。更新股票会导致事件触发,进而导致警报检查规则匹配。

  • 读者读者从某个来源获取股票警报。在这本书中,我们将从简单的列表或文件中获取更新,但你也可以构建其他读者以从互联网或其他地方获取更新。

在所有这些类中,管理股票信息的方式似乎是最简单的,所以让我们从这里开始。我们要做的是创建一个 Stock 类。这个类将保存当前股票的信息。它将存储当前价格和可能的一些最近价格历史。当我们想要匹配规则时,我们可以使用这个类。

要开始,创建一个名为 src 的目录。这个目录将保存我们所有的源代码。在这本书的其余部分,我们将把这个目录称为项目根目录。在 src 目录内,创建一个名为 stock_alerter 的子目录。这是我们将要实现股票警报模块的目录。

好的,让我们开始实现这个类。

不!等等!记得之前描述的 TDD 流程吗?第一步是编写测试,在我们编写实现代码之前。通过先编写测试,我们现在有机会思考我们想让这个类做什么。

那么,我们到底想让这个类做什么呢?让我们从一个简单的事情开始:

  • Stock 类应该用股票代码进行实例化

  • 一旦实例化,并且在任何更新之前,价格应该是 None

当然,我们还想让这个类做更多的事情,但我们会稍后再考虑。我们不会提出一个非常全面的特性列表,而是会一次关注一小块功能。现在,前面的期望已经足够了。

要将前面的期望转换为代码,在项目根目录下创建一个名为 stock.py 的文件,并将以下代码放入其中:

import unittest
class StockTest(unittest.TestCase):
    def test_price_of_a_new_stock_class_should_be_None(self):
        stock = Stock("GOOG")
        self.assertIsNone(stock.price)
if __name__ == "__main__":
    unittest.main()

这段代码做了什么?

  1. 首先,我们导入 unittest。这是我们将要使用的测试框架所在的库。幸运的是,它默认包含在 Python 标准库中,总是可用,所以我们不需要安装任何东西,可以直接导入模块。

  2. 第二,我们创建一个名为 StockTest 的类。这个类将包含 Stock 类的所有测试用例。这只是将相关的测试分组在一起的一种方便方式。没有规定每个类都应该有一个对应的测试类。有时,如果我们对一个类有很多测试,那么我们可能想为每个单独的行为创建单独的测试类,或者以其他方式分组测试。然而,在大多数情况下,为实际类创建一个测试类是最佳做法。

  3. 我们的 StockTest 类继承自 unittest 模块中的 TestCase 类。所有测试都需要继承这个类,以便被识别为测试类。

  4. 在课堂上,我们有一个方法。这个方法是一个测试用例。unittest 框架会选取任何以 test 开头的方法。这个方法的名字描述了测试要检查的内容。这样,当我们几个月后回来时,我们仍然记得测试做了什么。

  5. 测试创建了一个 Stock 对象,然后检查价格是否为 NoneassertIsNone 是我们从它继承的 TestCase 类提供的一个方法。它检查其参数是否为 None。如果参数不是 None,它将引发一个 AssertionError 并使测试失败。否则,执行将继续到下一行。由于那是方法的最后一行,测试完成并标记为通过。

  6. 最后一段检查模块是否直接从命令行执行。在这种情况下,__name__ 变量将具有 __main__ 的值,并且代码将执行 unittest.main() 函数。此函数将扫描当前文件中的所有测试并执行它们。我们需要在条件中包装此函数调用的原因是因为如果模块被导入到另一个文件中,这部分将不会执行。

恭喜!你已经完成了第一个失败的测试。通常,一个失败的测试会是一个令人担忧的原因,但在这个情况下,一个失败的测试意味着我们已经完成了过程的第一个步骤,可以继续到下一个步骤。

分析测试输出

现在我们已经编写了测试,是时候运行它了。要运行测试,只需执行文件。假设当前目录是 src 目录,以下是要执行文件的命令:

  • Windows:

    python.exe stock_alerter\stock.py
    
    
  • Linux/Mac:

    python3 stock_alerter/stock.py
    
    

如果 Python 可执行文件不在你的路径上,那么你将必须在这里给出完整的可执行文件路径。在某些 Linux 发行版中,文件可能被称为 python34python3.4 而不是 python3

当我们运行文件时,输出看起来如下所示:

E
=====================================================================
ERROR: test_price_of_a_new_stock_class_should_be_None (__main__.StockTest)
---------------------------------------------------------------------
Traceback (most recent call last):
 File "stock_alerter\stock.py", line 6, in test_price_of_a_new_stock_class_should_be_None
 stock = Stock("GOOG")
NameError: name 'Stock' is not defined
---------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (errors=1)

如预期,测试失败了,因为我们还没有创建 Stock 类。

让我们更详细地看看那个输出:

  • 第一行上的 E 表示测试产生了错误。如果测试通过了,那么你会在那一行看到一个点。一个失败的测试将被标记为 F。由于我们只有一个测试,所以那里只有一个字符。当我们有多个测试时,每个测试的状态将显示在同一行上,每个测试一个字符。

  • 在显示所有测试状态之后,我们将获得任何测试错误和失败的更详细说明。它告诉我们是否有失败或错误(在这种情况下用 ERROR 表示),以及测试的名称和它所属的类。随后是一个跟踪回溯,这样我们知道失败发生在哪里。

  • 最后,有一个总结显示了执行了多少个测试,有多少通过了或失败了,以及有多少产生了错误。

测试错误与测试失败

测试可能不通过有两个原因:它可能失败了,或者它可能引发了错误。这两个之间有一个小的区别。失败表示我们期望某种结果(通常通过断言),但得到了其他东西。例如,在我们的测试中,我们断言 stock.priceNone。假设 stock.price 除了 None 之外还有其他值,那么测试将失败。

错误表明发生了意外情况,通常是一个意外的异常被抛出。在我们之前的例子中,我们得到一个错误是因为Stock类尚未定义。

在这两种情况下,测试都没有通过,但原因不同,这些原因分别作为测试失败和测试错误单独报告。

使测试通过

现在我们有一个失败的测试,让我们让它通过。在stock.py文件中import unittest行之后添加以下代码:

class Stock:
    def __init__(self, symbol):
        self.symbol = symbol
        self.price = None

我们在这里所做的只是实现足够的代码以通过测试。我们创建了Stock类,这样测试就不会因为缺少它而抱怨,并且我们将price属性初始化为None

那么,这个类的其余实现呢?这可以稍后处理。我们现在的重点是让这个类的当前期望通过。随着我们编写更多的测试,我们最终也会实现这个类的更多部分。

再次运行文件,这次输出应该如下所示:

.
---------------------------------------------------------------------
Ran 1 test in 0.000s

OK

第一行有一个点,这表示测试正在通过。最后的OK消息告诉我们所有测试都已通过。

最后一步是对代码进行重构。由于代码量很少,实际上没有什么可以清理的。因此,我们可以跳过重构步骤,直接开始下一个测试。

重新组织测试代码

我们在同一个文件中添加了测试用例,这是为独立脚本和不太复杂的应用程序添加测试用例的一个好方法。然而,对于更大的应用程序,将测试代码与生产代码分开是一个好主意。

组织测试代码的这种模式有两种常见模式。

第一种模式是将测试代码保存在一个单独的根目录中,如下所示:

root
|
+- package
|  |
|  +- file1
|  +- file2
|
+- test
   |
   +- test_file1
   +- test_file2

另一种模式是将测试代码作为主代码的一个子模块,如下所示:

root
|
+- package
   |
   +- file1
   +- file2
   +- test
      |
      +- test_file1
      +- test_file2

第一种模式通常用于独立模块,因为它允许我们将代码和测试一起分发。测试通常可以在不进行大量设置或配置的情况下运行。第二种模式在应用程序需要打包而不包含测试代码时具有优势,例如在部署到生产服务器或向客户(在商业应用程序的情况下)分发时。然而,这两种模式都很受欢迎,主要取决于个人偏好,选择哪种方法。

我们将在本书中遵循第一种模式。要开始,在stock_alerter目录中创建一个名为tests的目录。接下来,在这个目录中创建一个名为test_stock.py的文件。我们将把所有的测试用例与源文件一一对应。这意味着,一个名为sample.py的文件将在tests/test_sample.py文件中有其测试用例。这是一个简单的命名约定,有助于快速定位测试用例。

最后,我们将我们的测试用例移动到这个文件中。我们还需要导入Stock类,以便在测试用例中使用它。我们的test_stock.py文件现在看起来如下:

import unittest
from ..stock import Stock

class StockTest(unittest.TestCase):
    def test_price_of_a_new_stock_class_should_be_None(self):
        stock = Stock("GOOG")
        self.assertIsNone(stock.price)

记得从stock.py中删除import unittest行,因为它现在不再包含测试代码。之前我们只有一个独立的脚本,但现在我们有一个stock_alerter模块和一个stock_alerter.tests子模块。由于我们现在正在使用模块,我们还应该在stock_alertertests目录中添加一个空的__init__.py文件。

我们现在的文件布局应该如下所示:

src
|
+- stock_alerter
   |
   +- __init__.py
   +- stock.py
   +- tests
      +- __init__.py
      +- test_stock.py

重新组织后的测试运行

如果你已经注意到,测试代码中不再有对unittest.main()的调用。在单个脚本中包含对unittest.main()的调用效果很好,因为它允许我们通过简单地执行文件来运行测试。然而,这不是一个可扩展的解决方案。如果我们有数百个文件,我们希望一次性运行所有测试,而不必逐个执行每个文件。

为了解决这个问题,Python 3 从命令行提供了非常出色的测试发现和执行能力。只需进入src目录并运行以下命令:

  • Windows:

    python.exe -m unittest
    
    
  • Linux/Mac:

    python3 -m unittest
    
    

此命令将遍历当前目录及其所有子目录,并运行找到的所有测试。这是默认的自动发现执行模式,其中命令搜索所有文件并运行测试。自动发现也可以通过以下命令显式运行:

python3 -m unittest discover

可以使用以下参数自定义自动发现,以检查特定目录或文件:

  • -s start_directory:指定发现应开始的起始目录。默认为当前目录。

  • -t top_directory:指定顶级目录。这是从该目录执行导入的目录。如果起始目录位于包内并且由于导入错误而出现错误,则此选项很重要。默认为起始目录。

  • -p file_pattern:用于识别测试文件的文件模式。默认情况下,它检查以test开头的 Python 文件。如果我们给测试文件命名其他名称(例如,stock_test.py),那么我们必须传递此参数,以便正确地将文件识别为测试文件。

为了说明起始目录和顶级目录之间的区别,请从src目录运行以下命令:

python3 -m unittest discover -s stock_alerter

前面的命令将因导入错误而失败。原因是当起始目录设置为stock_alerter时,tests目录被导入为顶级模块,相对导入失败。为了解决这个问题,我们需要使用以下命令:

python3 -m unittest discover -s stock_alerter -t .

此命令将导入相对于顶级目录的所有模块,因此stock_alerter正确地成为主模块。

你也可以禁用自动发现,并仅指定要运行的某些测试:

  • 传递一个模块名称将只运行该模块内的测试。例如,使用python3 -m unittest stock_alerter.tests.test_stock将只运行test_stock.py中的测试。

  • 您可以将范围进一步细化到特定的类或方法,例如使用python3 -m unittest stock_alerter.tests.test_stock.StockTest

摘要

恭喜!您已经完成了一个 TDD 的循环。如您所见,每个循环都非常快。有些循环,比如我们刚刚经历的,可以在几秒钟内完成。其他循环可能涉及相当多的清理工作,可能需要相当长的时间。每个循环都会实现一个小测试,一小部分功能以通过测试,然后进行一些清理以确保代码质量。

在本章中,我们探讨了 TDD 是什么,它与其他形式的单元和集成测试有何不同,并编写了我们的第一个测试。

到目前为止,我们的实现仍然非常小且非常简单。您可能会想知道,为了编写和实现这四行非常简单的代码,所有的炒作是否都值得。在接下来的几章中,我们将通过示例进一步深入探讨过程。

第二章 红-绿-重构 – TDD 循环

在上一章中,我们通过创建一个失败的测试然后使其通过,进行了一个小的 TDD 循环。在这一章中,我们将通过编写更多测试来完善Stock类的其余部分。在这个过程中,我们将更深入地了解 TDD 循环和unittest模块。

测试是可执行的要求

在第一次测试中,我们编写了一个非常简单的测试,用来检查新的Stock类是否将price属性初始化为None。我们现在可以思考接下来想要实现哪些要求。

一个细心的读者可能会注意到上一句中使用的术语,我说我们可以思考接下来要实现的要求,而不是说我们可以思考接下来要编写的测试。这两个陈述是等效的,因为在 TDD(测试驱动开发)中,测试不过是要求。每次我们编写一个测试并实现代码使其通过,我们实际上是在使代码满足某些要求。从另一个角度来看,测试只是可执行的规格说明。需求文档往往与实际实现脱节,但测试则不可能出现这种情况,因为一旦它们脱节,测试就会失败。

在上一章中,我们提到Stock类将用于存储股票符号的价格信息和价格历史。这表明我们需要一种方法来设置价格,每次更新时都要使用。让我们实现一个满足以下要求的update方法:

  • 它应该接受时间戳和价格值,并在对象上设置它

  • 价格不能为负

  • 经过多次更新后,该对象会给我们提供最新的价格

安排-行动-断言

让我们从第一个要求开始。以下是测试代码:

    def test_stock_update(self):
        """An update should set the price on the stock object
        We will be using the `datetime` module for the timestamp
        """
        goog = Stock("GOOG")
        goog.update(datetime(2014, 2, 12), price=10)
        self.assertEqual(10, goog.price)

在这里,我们调用update方法(目前还不存在)并传入时间戳和价格,然后检查价格是否已正确设置。我们使用unittest.TestCase类提供的assertEqual方法来断言值。

由于我们使用datetime模块来设置时间戳,我们将在文件顶部添加from datetime import datetime这一行,以便它能够运行。

这个测试遵循 Arrange-Act-Assert 模式。

  1. 安排:为测试设置上下文。在这种情况下,我们创建一个Stock对象。在其他测试中,可能需要创建多个对象或将一些东西连接起来,以便特定测试需要。

  2. 行动:执行我们想要测试的操作。在这里,我们使用适当的参数调用update方法。

  3. 断言:最后我们断言结果符合预期。

在这个测试中,模式的每一部分都占用了一行代码,但这并不总是如此。通常,测试的每一部分会有多行代码。

记录我们的测试

当我们运行测试时,我们得到以下输出:

.E
==================================================================
ERROR: test_stock_update (__main__.StockTest)
An update should set the price on the stock object
------------------------------------------------------------------
Traceback (most recent call last):
 File "stock_alerter\stock.py", line 22, in test_stock_update
 goog.update(datetime(2014, 2, 12), price=10)
AttributeError: 'Stock' object has no attribute 'update'

------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (errors=1)

测试如预期失败,但有趣的是,文档字符串的第一行在第四行打印出来。这很有用,因为它提供了更多关于哪个案例失败的信息。这显示了使用第一行作为简短摘要,其余的文档字符串作为更详细解释的另一种记录测试的方法。详细的解释在测试失败时不会打印出来,所以不会影响测试失败的输出。

我们使用了两种测试文档的方法:

  • 编写描述性的测试方法名称

  • 在文档字符串中添加解释

哪个更好?大多数情况下,测试是自我解释的,不需要很多背景解释。在这种情况下,一个命名良好的测试方法就足够了。

然而,有时测试方法名称变得非常长,以至于变得笨拙,实际上降低了代码的可读性。在其他时候,我们可能想要更详细地解释我们在测试什么以及为什么测试。在这种情况下,缩短方法名称并在文档字符串中添加解释是一个好主意。

这里是实现通过这个测试的方法:

    def update(self, timestamp, price):
        self.price = price

这个最小化实现通过了测试。就像第一次实现一样,我们并不是试图实现全部功能。我们只想实现足够的部分来通过测试。记住,当测试通过时,意味着需求得到了满足。在这个阶段,我们有两个通过测试,实际上我们没有太多需要重构的地方,所以让我们继续前进。

测试异常

另一个要求是价格不能为负。如果价格是负数,我们希望抛出一个 ValueError。我们如何在测试中检查这个期望呢?这里有一种方法可以做到:

    def test_negative_price_should_throw_ValueError(self):
        goog = Stock("GOOG")
        try:
            goog.update(datetime(2014, 2, 13), -1)
        except ValueError:
            return
        self.fail("ValueError was not raised")

在前面的代码中,我们使用负价格调用 update 方法。这个调用被 try...except 块包裹,以捕获 ValueError。如果异常被正确抛出,控制将进入 except 块,我们在那里从测试中返回。由于测试方法成功返回,它被标记为通过。如果没有抛出异常,则调用 fail 方法。这是 unittest.TestCase 提供的另一个方法,当它被调用时,会抛出一个测试失败异常。我们可以传递一个消息来提供一些解释,说明为什么它失败了。

这里是通过这个测试的代码:

    def update(self, timestamp, price):
        if price < 0:
            raise ValueError("price should not be negative")
        self.price = price

使用这段代码,到目前为止的所有三个测试都通过了。

由于检查异常是一个相当常见的案例,unittest 提供了一种更简单的方式来处理:

    def test_negative_price_should_throw_ValueError(self):
        goog = Stock("GOOG")
        self.assertRaises(ValueError, goog.update, datetime(2014, 2, 13), -1)

assertRaises 方法将期望的异常作为第一个参数,将需要调用的函数作为第二个参数,函数的参数通过剩余的参数传递。如果你需要使用关键字参数调用函数,那么它们可以作为关键字参数传递给 assertRaises 方法。

注意

注意,assertRaises 的第二个参数是对要调用的函数的引用。这就是为什么我们在函数名称后面不放置括号的原因。

如果传入函数引用和参数列表感觉不自然,那么 assertRaises 提供了另一种我们可以使用的语法:

    def test_negative_price_should_throw_ValueError(self):
        goog = Stock("GOOG")
        with self.assertRaises(ValueError):
            goog.update(datetime(2014, 2, 13), -1)

这里发生了什么?当我们只向 assertRaises 传递一个参数时,会返回一个上下文管理器。我们可以使用 with 语句,并将我们的操作放在该块中。如果该块抛出了预期的异常,那么上下文管理器会匹配它并退出块而不会出现错误。然而,如果块中没有抛出预期的异常,那么上下文管理器在块退出时会引发失败。

探索断言方法

现在我们对 update 的要求只剩下一个:

  • -Done- 它应该接受一个时间戳和价格值,并将其设置在对象上

  • -Done- 价格不能为负

  • 经过多次更新后,对象给出了最新的价格

让我们来看剩下的要求。以下是测试:

    def test_stock_price_should_give_the_latest_price(self):
        goog = Stock("GOOG")
        goog.update(datetime(2014, 2, 12), price=10)
        goog.update(datetime(2014, 2, 13), price=8.4)
        self.assertAlmostEqual(8.4, goog.price, delta=0.0001)

这个测试所做的只是简单地调用 update 两次,并在我们请求价格时提供最新的价格。测试的有趣之处在于我们在这里使用了 assertAlmostEqual 方法。这种方法通常用于检查浮点数的相等性。我们为什么不使用普通的 assertEqual 呢?原因是由于浮点数的存储方式,结果可能不会正好是您期望的数字。您期望的值和实际存储的值之间可能存在一个非常小的差异。考虑到这一点,assertAlmostEqual 方法允许我们在比较中指定公差。例如,如果我们期望 8.4 但实际值是 8.39999999,测试仍然会通过。

assertAlmostEqual 方法有两种指定公差的方式。我们上面使用的方法涉及传递一个 delta 参数,表示预期值和实际值之间的差异应在 delta 范围内。我们上面指定的 delta 参数是 0.0001,这意味着任何在 8.3999 和 8.4001 之间的值都会通过测试。

指定公差的其他方法是使用以下代码中所示的 places 参数:

        self.assertAlmostEqual(8.4, goog.price, places=4)

如果使用此参数,则在比较之前,预期的值和实际的值都会四舍五入到指定的十进制位数。请注意,您需要传递 delta 参数或 places 参数。同时传递这两个参数是错误的。

到目前为止,我们已经使用了以下断言方法:

  • assertIsNone

  • assertEqual

  • assertRaises

  • assertAlmostEqual

  • fail

unittest 模块提供了大量我们可以用于各种条件的断言方法。以下列出了一些常见的:

  • assertFalse(x, msg)assertTrue(x, msg)

  • assertIsNone(x, msg)assertIsNotNone(x, msg)

  • assertEqual(x, y, msg)assertNotEqual(x, y, msg)

  • assertAlmostEqual(x, y, places, msg, delta)assertNotAlmostEqual(x, y, places, msg, delta)

  • assertGreater(x, y, msg)assertGreaterEqual(x, y, msg)

  • assertLess(x, y, msg)assertLessEqual(x, y, msg)

  • assertIs(x, y, msg)assertIsNot(x, y, msg)

  • assertIn(x, seq, msg)assertNotIn(x, seq, msg)

  • assertIsInstance(x, cls, msg)assertNotIsInstance(x, cls, msg)

  • assertRegex(text, regex, msg)assertNotRegex(text, regex, msg)

  • assertRaises(exception, callable, *args, **kwargs)

  • fail(msg)

大多数前面的函数都是自解释的。以下是一些需要一些解释的点:

  • msg参数:大多数断言方法都接受一个可选的消息参数。可以在这里传递一个字符串,如果断言失败,它将被打印出来。通常,默认消息已经非常描述性,因此不需要此参数。大多数时候它与fail方法一起使用,就像我们刚才看到的那样。

  • assertEqualassertIs:这两组断言非常相似。关键的区别在于前者检查的是*相等性*,而后者断言用于检查对象的*身份*。第二个断言在之前的例子中失败,因为尽管两个对象相等,但它们仍然是两个不同的对象,因此它们的身份是不同的:

    >>> test = unittest.TestCase()
    >>> test.assertEqual([1, 2], [1, 2])  # Assertion Passes
    >>> test.assertIs([1, 2], [1, 2])     # Assertion Fails
    Traceback (most recent call last):
     File "<stdin>", line 1, in <module>
     File "C:\Python34\lib\unittest\case.py", line 1067, in assertIs
     self.fail(self._formatMessage(msg, standardMsg))
     File "C:\Python34\lib\unittest\case.py", line 639, in fail
     raise self.failureException(msg)
    AssertionError: [1, 2] is not [1, 2]
    
    
  • assertIn/assertNotIn: 这些断言用于检查一个元素是否在序列中。这包括字符串、列表、集合以及任何支持in操作符的其他对象。

  • assertIsInstance/assertNotIsInstance: 它们检查一个对象是否是给定类的实例。cls参数也可以是一个类元组,用于断言对象是这些类中的任何一个的实例。

unittest模块还提供了一些不太常用的断言:

  • assertRaisesRegex(exception, regex, callable, *args, **kwargs): 这个断言与assertRaises类似,但它还额外接受一个regex参数。可以在这里传递一个正则表达式,断言将检查是否抛出了正确的异常,以及异常消息是否与正则表达式匹配。

  • assertWarns(warning, callable, *args, **kwargs): 它与assertRaises类似,但检查是否抛出了警告。

  • assertWarnsRegex(warning, callable, *args, **kwargs): 它是assertRaisesRegex的警告等效。

特定断言与通用断言

可能会有人问一个问题,为什么有这么多不同的断言方法。为什么我们不能像以下代码中所示的那样使用assertTrue而不是更具体的断言呢:

assertInSeq(x, seq)
assertTrue(x in seq)

assertEqual(10, x)
assertTrue(x == 10)

虽然它们确实等价,但使用特定断言的一个动机是,如果断言失败,你会得到更好的错误消息。当比较列表和字典等对象时,错误消息将显示差异的确切位置,这使得理解更容易。因此,建议尽可能使用更具体的断言。

设置和清理

让我们看看我们迄今为止所做的测试:

    def test_price_of_a_new_stock_class_should_be_None(self):
        stock = Stock("GOOG")
        self.assertIsNone(stock.price)

    def test_stock_update(self):
        """An update should set the price on the stock object
        We will be using the `datetime` module for the timestamp
        """
        goog = Stock("GOOG")
        goog.update(datetime(2014, 2, 12), price=10)
        self.assertEqual(10, goog.price)

    def test_negative_price_should_throw_ValueError(self):
        goog = Stock("GOOG")
        with self.assertRaises(ValueError):
            goog.update(datetime(2014, 2, 13), -1)

    def test_stock_price_should_give_the_latest_price(self):
        goog = Stock("GOOG")
        goog.update(datetime(2014, 2, 12), price=10)
        goog.update(datetime(2014, 2, 13), price=8.4)
        self.assertAlmostEqual(8.4, goog.price, delta=0.0001)

如果你注意到,每个测试都通过实例化一个 Stock 对象来进行相同的设置,该对象随后用于测试。在这种情况下,设置只是一行代码,但有时我们可能需要在运行测试之前执行多个步骤。我们可以在每个测试中重复设置代码,而不是使用 TestCase 类提供的 setUp 方法:

    def setUp(self):
        self.goog = Stock("GOOG")

    def test_price_of_a_new_stock_class_should_be_None(self):
        self.assertIsNone(self.goog.price)

    def test_stock_update(self):
        """An update should set the price on the stock object We will be  using the `datetime` module for the timestamp """
        self.goog.update(datetime(2014, 2, 12), price=10)
        self.assertEqual(10, self.goog.price)

    def test_negative_price_should_throw_ValueError(self):
        with self.assertRaises(ValueError):
            self.goog.update(datetime(2014, 2, 13), -1)

    def test_stock_price_should_give_the_latest_price(self):
        self.goog.update(datetime(2014, 2, 12), price=10)
        self.goog.update(datetime(2014, 2, 13), price=8.4)
        self.assertAlmostEqual(8.4, self.goog.price, delta=0.0001)

在前面的代码中,我们正在用我们自己的方法覆盖默认的 setUp 方法。我们将设置代码放在这个方法中。这个方法在每个测试之前执行,因此在这里完成的初始化可用于我们的测试方法。请注意,我们必须更改我们的测试以使用 self.goog,因为它现在已成为实例变量。

setUp 类似,还有一个 tearDown 方法,它在测试执行后立即执行。我们可以在该方法中执行任何必要的清理操作。

setUptearDown 方法在每个测试前后执行。如果我们想为测试组只执行一次设置,怎么办?可以将 setUpClasstearDownClass 方法实现为类方法,并且它们将只在每个测试类中执行一次。同样,setUpModuletearDownModule 函数可用于在整个模块中只初始化一次。以下示例显示了执行顺序:

import unittest

def setUpModule():
    print("setUpModule")

def tearDownModule():
    print("tearDownModule")

class Class1Test(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print("  setUpClass")

    @classmethod
    def tearDownClass(cls):
        print("  tearDownClass")

    def setUp(self):
        print("       setUp")

    def tearDown(self):
        print("       tearDown")

    def test_1(self):
        print("         class 1 test 1")

    def test_2(self):
        print("         class 1 test 2")

class Class2Test(unittest.TestCase):
    def test_1(self):
        print("         class 2 test 1")

当运行此代码时,输出如下:

setUpModule
  setUpClass
       setUp
         class 1 test 1
       tearDown
       setUp
         class 1 test 2
       tearDown
  tearDownClass
         class 2 test 1
tearDownModule

如我们所见,模块级别的设置首先执行,然后是类级别,最后是测试用例级别。清理操作以相反的顺序执行。在实际使用中,测试用例级别的 setUptearDown 方法非常常用,而类级别和模块级别的设置则不需要太多。类级别和模块级别的设置仅在存在昂贵的设置步骤时使用,例如连接数据库或远程服务器,并且最好只设置一次并共享给所有测试。

注意

使用类级别和模块级别设置时的警告

在类和模块级别进行的任何初始化都是在测试之间共享的。因此,确保在一个测试中进行的修改不会影响另一个测试非常重要。例如,如果我们已经在 setUpClass 中初始化了 self.goog = Stock("GOOG"),那么如果其他测试在它之前执行并改变了对象的状态,那么第一个测试检查新 Stock 对象的价格应该是 None 将会失败。

记住,测试运行的顺序是不确定的。测试应该是独立的,并且无论执行顺序如何,都应该通过。因此,谨慎使用setUpClasssetUpModule至关重要,以确保只在测试之间可以重用的状态下设置。

脆弱的测试

我们已经实现了update方法的三项要求:

  • -完成- 它应该接受时间戳和价格值,并将它们设置在对象上

  • -完成- 经过多次更新后,对象会给出最新的价格

  • -完成- 价格不能为负

现在,让我们假设出现了一个我们之前不知道的新要求:

  • Stock类需要一个方法来检查股票是否有上升趋势。上升趋势是指最新的三个更新值都比前一个更新值高。

到目前为止,我们的Stock实现只是存储最新的价格。为了实现这一功能,我们需要存储一些过去的价格历史值。一种方法是将price变量改为列表。问题是当我们改变实现内部结构时,它将破坏我们所有的测试,因为它们都直接访问price变量并断言它具有特定的值。

我们看到的是测试脆弱性的一个例子。

当实现细节的改变需要更改测试用例时,测试就变得脆弱。理想情况下,测试应该测试接口而不是直接测试实现。毕竟,接口是其他单元将用来与这个单元交互的。当我们通过接口进行测试时,它允许我们有自由地更改代码实现,而不必担心破坏测试。

注意

测试可能失败的三种方式:

  • 如果在测试的代码中引入了错误

  • 如果测试与实现紧密耦合,并且我们对代码进行更改以修改实现,但没有引入错误(例如,重命名变量或修改内部设计)

  • 如果测试需要一些不可用的资源(例如,连接到外部服务器,但服务器已关闭)

理想情况下,第一个情况应该是测试失败的唯一情况。我们应该尽可能避免第二个和第三个。

有时测试特定的实现细节可能很重要。例如,假设我们有一个类,它预期执行复杂的计算并将结果缓存以供将来使用。测试缓存功能唯一的方法是验证计算值是否存储在缓存中。如果我们后来更改缓存方法(例如,从文件缓存切换到 memcache),那么我们也必须更改测试。

碎片化测试可能比没有测试更糟糕,因为每次实现变更都需要修复十个或二十个测试的维护开销可能会让开发者远离测试驱动开发(TDD),增加挫败感,并导致团队禁用或跳过测试。以下是一些关于如何考虑测试碎片化的指南:

  • 如果可能的话,避免在测试中使用实现细节,只使用公开的接口。这包括在设置代码和断言中只使用接口方法。

  • 如果测试需要检查被测试单元内部的特定功能,并且这是一个重要的功能,那么检查特定的实现动作可能是有意义的。

  • 如果使用外部接口设置我们想要的确切状态很麻烦,或者没有接口方法可以检索我们想要断言的特定值,那么我们可能需要在测试中查看实现细节。

  • 如果我们相当有信心,实现细节在未来不太可能发生变化,那么我们可能会继续在测试中使用特定于实现的细节。

对于第二种和第三种情况,重要的是要理解在便利性、测试可读性和碎片化之间存在权衡。没有正确答案,这是一个主观决定,需要权衡每种具体情况的利弊。

重新设计

在上一节中,我们讨论了检查股票是否有上升趋势的新要求。

让我们先从编写一个测试开始:

class StockTrendTest(unittest.TestCase):
    def setUp(self):
        self.goog = Stock("GOOG")
    def test_increasing_trend_is_true_if_price_increase_for_3_updates(self):
        timestamps = [datetime(2014, 2, 11), datetime(2014, 2, 12), datetime(2014, 2, 13)]
        prices = [8, 10, 12]
        for timestamp, price in zip(timestamps, prices):
            self.goog.update(timestamp, price)
        self.assertTrue(self.goog.is_increasing_trend())

这个测试接受三个时间戳和价格,并对每个价格执行更新。由于所有三个价格都在增加,is_increasing_trend方法应该返回True

要使这个测试通过,我们首先需要添加支持存储价格历史的功能。

在初始化器中,让我们将price属性替换为price_history列表。这个列表将存储价格更新的历史,每个新的更新都添加到列表的末尾:

    def __init__(self, symbol):
        self.symbol = symbol
        self.price_history = []

注意

在进行这个更改后,现在所有测试都将失败,包括之前通过的那些。只有在我们完成几个步骤后,我们才能再次使测试通过。当测试通过时,我们可以不断运行测试,以确保我们的更改没有破坏任何功能。当测试失败时,我们没有这样的安全网。某些设计更改,就像我们现在所做的,在完成一系列更改之前,不可避免地会使许多测试暂时失败。我们应该尽量减少在测试失败时进行更改的时间,一次只做小改动。这允许我们在进行过程中验证我们的更改。

我们现在可以将update方法更改为将新价格存储在这个列表中。

    def update(self, timestamp, price):
        if price < 0:
            raise ValueError("price should not be negative")
            self.price_history.append(price)

我们将保留当前通过访问price属性获取最新价格的用户界面。然而,由于我们已经用price_history列表替换了price属性,我们需要创建一个属性来模拟现有的界面:

    @property
    def price(self):
        return self.price_history[-1] \
        if self.price_history else None

通过这个更改,我们可以再次运行测试,并看到我们所有的先前测试仍然通过,只有新的趋势功能测试失败。

新的设计现在允许我们实现代码以通过趋势测试:

    def is_increasing_trend(self):
        return self.price_history[-3] < \
        self.price_history[-2] < self.price_history[-1]

该方法的实现只是简单地检查最后三个价格更新是否在增加。代码实现后,包括新的测试在内,我们所有的测试都将通过。

注意

关于属性的快速入门

属性是 Python 的一个特性,我们可以将属性访问委托给一个函数。由于我们将价格声明为属性,访问Stock.price将导致调用该方法而不是搜索属性。在我们的实现中,它允许我们创建一个接口,这样其他模块可以像属性一样引用股票价格,尽管在对象中实际上没有这样的属性。

重构测试

第一个测试通过后,我们可以继续进行第二个测试:

    def test_increasing_trend_is_false_if_price_decreases(self):
        timestamps = [datetime(2014, 2, 11), datetime(2014, 2, 12), \ 
            datetime(2014, 2, 13)]
        prices = [8, 12, 10]
        for timestamp, price in zip(timestamps, prices):
            self.goog.update(timestamp, price)
        self.assertFalse(self.goog.is_increasing_trend())

我们的实现已经通过了这个测试,所以让我们继续进行第三个测试:

    def test_increasing_trend_is_false_if_price_equal(self):
        timestamps = [datetime(2014, 2, 11), datetime(2014, 2, 12), \ 
            datetime(2014, 2, 13)]
        prices = [8, 10, 10]
        for timestamp, price in zip(timestamps, prices):
            self.goog.update(timestamp, price)
        self.assertFalse(self.goog.is_increasing_trend())

当前的代码也通过了这个测试。但让我们在这里暂停一下。如果我们看看到目前为止的测试用例,我们可以看到在测试之间有很多代码被重复使用。设置代码也不是很易读。这里最重要的行是价格列表,它被隐藏在混乱中。我们需要清理一下。我们要做的是将公共代码放入辅助方法中:

class StockTrendTest(unittest.TestCase):
    def setUp(self):
        self.goog = Stock("GOOG")

    def given_a_series_of_prices(self, prices):
        timestamps = [datetime(2014, 2, 10), datetime(2014, 2, \ 
            11), datetime(2014, 2, 12), datetime(2014, 2, 13)]
        for timestamp, price in zip(timestamps, prices):
            self.goog.update(timestamp, price)

    def test_increasing_trend_is_true_if_price_increase_for_3_updates(self):
        self.given_a_series_of_prices([8, 10, 12])
        self.assertTrue(self.goog.is_increasing_trend())

    def test_increasing_trend_is_false_if_price_decreases(self):
        self.given_a_series_of_prices([8, 12, 10])
        self.assertFalse(self.goog.is_increasing_trend())

    def test_increasing_trend_is_false_if_price_equal(self):
        self.given_a_series_of_prices([8, 10, 10])
        self.assertFalse(self.goog.is_increasing_trend())

太好了!不仅消除了重复,测试的可读性也大大提高。默认情况下,unittest模块会寻找以单词test开头的方法,并且只执行这些方法作为测试,因此我们的辅助方法被误认为是测试用例的风险很小。

注意

记住,测试用例也是代码。所有关于编写干净、可维护和可读代码的规则也适用于测试用例。

探索规则类

到目前为止,我们一直专注于Stock类。现在让我们将注意力转向规则类。

注意

从这本书的这个点开始,我们将查看实现代码,然后展示我们如何有效地测试它。注意,这并不意味着先写代码,然后写单元测试。TDD 过程仍然是先测试,然后是实现。是测试用例将驱动实现策略。我们首先展示实现代码,只是为了更容易理解接下来的测试概念。所有这些代码最初都是先写测试!

规则类跟踪用户想要跟踪的规则,并且它们可以是不同类型的。例如,当股票价格超过某个值或符合某种趋势时发送警报。

下面是一个PriceRule实现的示例:

class PriceRule:
        """PriceRule is a rule that triggers when a stock price
        satisfies a condition (usually greater, equal or lesser
        than a given value)"""

    def __init__(self, symbol, condition):
        self.symbol = symbol
        self.condition = condition

    def matches(self, exchange):
        try:
            stock = exchange[self.symbol]
        except KeyError:
            return False
        return self.condition(stock) if stock.price else False

    def depends_on(self):
        return {self.symbol}

此类使用股票符号和条件进行初始化。条件可以是一个 lambda 或函数,它接受一个股票作为参数并返回TrueFalse。规则匹配当股票匹配条件时。此类的关键方法是matches方法。此方法根据规则是否匹配返回TrueFalsematches方法接受一个交易所作为参数。这只是一个包含所有可用于应用程序的股票的字典。

我们还没有讨论depends_on方法。此方法仅返回哪些股票更新依赖于规则。这将在稍后用于检查任何特定股票更新时的规则。对于PriceRule,它仅依赖于在初始化器中传递的股票。一个细心的读者会注意到它返回一个集合(花括号),而不是列表。

将此规则代码放入stock_alerter目录下的rule.py文件中。

下面是如何使用PriceRule的示例:

>>> from datetime import datetime
>>> from stock_alerter.stock import Stock
>>> from stock_alerter.rule import PriceRule
>>>
>>> # First, create the exchange
>>> exchange = {"GOOG": Stock("GOOG"), "MSFT": Stock("MSFT")}
>>>
>>> # Next, create the rule, checking if GOOG price > 100
>>> rule = PriceRule("GOOG", lambda stock: stock.price > 100)
>>>
>>> # No updates? The rule is False
>>> rule.matches(exchange)
False
>>>
>>> # Price does not match the rule? Rule is False
>>> exchange["GOOG"].update(datetime(2014, 2, 13), 50)
>>> rule.matches(exchange)
False
>>>
>>> # Price matches the rule? Rule is True
>>> exchange["GOOG"].update(datetime(2014, 2, 13), 101)
>>> rule.matches(exchange)
True
>>>

下面是一些测试的示例:

class PriceRuleTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        goog = Stock("GOOG")
        goog.update(datetime(2014, 2, 10), 11)
        cls.exchange = {"GOOG": goog}

    def test_a_PriceRule_matches_when_it_meets_the_condition(self):
        rule = PriceRule("GOOG", lambda stock: stock.price > 10)
        self.assertTrue(rule.matches(self.exchange))

    def test_a_PriceRule_is_False_if_the_condition_is_not_met(self):
        rule = PriceRule("GOOG", lambda stock: stock.price < 10)
        self.assertFalse(rule.matches(self.exchange))

    def test_a_PriceRule_is_False_if_the_stock_is_not_in_the_exchange(self):
        rule = PriceRule("MSFT", lambda stock: stock.price > 10)
        self.assertFalse(rule.matches(self.exchange))

    def test_a_PriceRule_is_False_if_the_stock_hasnt_got_an_update_yet(self):
        self.exchange["AAPL"] = Stock("AAPL")
        rule = PriceRule("AAPL", lambda stock: stock.price > 10)
        self.assertFalse(rule.matches(self.exchange))

    def test_a_PriceRule_only_depends_on_its_stock(self):
        rule = PriceRule("MSFT", lambda stock: stock.price > 10)
        self.assertEqual({"MSFT"}, rule.depends_on())

需要注意的一点是我们如何使用setupClass方法来进行设置。如前所述,此方法只为整个测试系列调用一次。我们使用此方法来设置交易所并存储它。请记住在setupClass方法上放置@classmethod装饰器。我们在类中存储交易所,并在测试中使用self.exchange来访问它。

否则,测试只是构建一个规则并检查匹配方法。

注意

装饰器(非常)快速入门

装饰器是接受一个函数作为输入并返回另一个函数作为输出的函数。Python 有一个简写语法,我们可以通过在函数或方法上方使用@decorator来应用装饰器。有关更多详细信息,请参阅 Python 文档或教程。一个好的教程是simeonfranklin.com/blog/2012/jul/1/python-decorators-in-12-steps/

现在我们来看另一个规则类,即AndRule。当您想要组合两个或更多规则时,会使用AndRule,例如,AAPL > 10 AND GOOG > 15

下面是如何为它编写测试的示例:

class AndRuleTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        goog = Stock("GOOG")
        goog.update(datetime(2014, 2, 10), 8)
        goog.update(datetime(2014, 2, 11), 10)
        goog.update(datetime(2014, 2, 12), 12)
        msft = Stock("MSFT")
        msft.update(datetime(2014, 2, 10), 10)
        msft.update(datetime(2014, 2, 11), 10)
        msft.update(datetime(2014, 2, 12), 12)
        redhat = Stock("RHT")
        redhat.update(datetime(2014, 2, 10), 7)
        cls.exchange = {"GOOG": goog, "MSFT": msft, "RHT": redhat}

    def test_an_AndRule_matches_if_all_component_rules_are_true(self):
        rule = AndRule(PriceRule("GOOG", lambda stock: stock.price > 8), PriceRule("MSFT", lambda stock: stock.price > 10))
        self.assertTrue(rule.matches(self.exchange))

编写测试的第一件事是它让我们思考类将如何被使用。

例如,我们应该如何将各种子规则传递给AndRule?我们应该有一个设置它们的方法吗?我们应该将它们作为列表传递?我们应该将它们作为单独的参数传递?这是一个设计决策,首先创建测试允许我们作为类的用户实际编写代码,并确定哪种选择最佳。在上面的测试中,我们决定将每个子规则作为单独的参数传递给AndRule构造函数。

现在决策已经做出,我们可以编写一些代码来通过测试:

class AndRule:
    def __init__(self, *args):
        self.rules = args

    def matches(self, exchange):
        return all([rule.matches(exchange) for rule in self.rules])

在这里,我们可以看到测试驱动过程如何帮助我们驱动代码的设计。

注意

all函数

all函数是一个内置函数,它接受一个列表,并且只有当列表中的每个元素都是True时才返回True

练习

现在是时候将我们新学的技能付诸实践了。以下是向Stock类中添加的新要求:

  • 有时,更新可能会出现顺序问题,我们可能会先收到一个较新时间戳的更新,然后是较旧时间戳的更新。这可能是由于随机网络延迟,或者有时我们可能从不同的来源收到更新,其中一个可能稍微领先于另一个。

  • Stock类应该能够处理这种情况,并且price属性应该根据时间戳返回最新的价格。

  • is_increasing_trend也应该根据其时间戳处理最新的三个价格。

尝试实现这个要求。不要对这些方法的现有接口进行任何更改,但请随意根据需要更改实现。以下是一些需要考虑的事项:

  • 我们现有的设计支持这个新特性吗?我们需要对当前设计做出任何更改吗?

  • 我们将为此要求编写什么样的测试?

  • 在我们让一切正常运行之后,我们是否可以进行一些清理工作,使代码更易于阅读或维护?

  • 我们在做出这个更改后是否需要更改现有的测试,或者它们是否无需修改就能继续工作?

在练习结束时,你应该让所有现有的测试通过,以及为这个要求编写的任何新测试。完成后,你可以查看附录 A,练习答案,以获取这个练习的一个可能的解决方案。

摘要

在本章中,我们更详细地研究了 TDD 周期。我们学习了 Arrange-Act-Assert 模式,更详细地研究了提供的各种断言,以及设置测试和之后的清理的一些不同方法。最后,我们探讨了如何防止测试过于脆弱,并进行了一些基本的重构。

第三章 代码异味和重构

在上一章中,我们更详细地介绍了 TDD 周期。在本章中,我们将探讨相关的概念:代码异味和重构。

遵循测试驱动开发过程的最大优点之一是,我们编写的测试总是存在,以确保我们不会破坏任何东西。这为我们提供了一个安全网,可以随意修改代码,并确保代码易于阅读、易于维护且编写良好。没有测试,我们总是对即将破坏某物感到担忧,而且往往决定什么都不做。这导致代码随着时间的推移而退化,直到它变得如此混乱,以至于没有人愿意再触碰它。结果是,实现新功能所需的时间更长,这不仅因为设计混乱,而且因为我们必须进行广泛的测试以确保现有功能没有破坏。

因此,我们绝对不能跳过 TDD 周期的第三步:重构。

注意

本章的代码从上一章的练习完成后的点开始。参见附录 A,练习答案,了解练习中做出的更改,或者从github.com/siddhi/test_driven_python下载本章代码的起点。

双交叉移动平均

在上一章中,我们编写了一个检查上升趋势的方法。在本章中,我们将通过开发一个检查双交叉移动平均的方法来继续那个例子。

双交叉移动平均DMAC)是一个简单的指标,用于显示股票的短期趋势与长期趋势相比。

下图显示了 DMAC 的工作原理:

双交叉移动平均

考虑一个股票,其收盘价如上所示。首先,我们计算两个移动平均趋势。短期(5 天)移动平均是通过计算较短天数内的移动平均得到的。长期移动平均是通过计算较长天数内的移动平均得到的,例如最后 10 天的移动平均。

当我们绘制长期和短期移动平均图时,我们会看到在某些点上,短期图从低于长期图交叉到高于长期图。这一点代表一个买入信号。在其他点上,短期图从上方交叉到下方。这一点代表一个卖出信号。在所有其他点上,不应采取任何行动。

实现双交叉移动平均

我们将要实现一个名为get_crossover_signal的方法,用于Stock类。以下是该方法的以下要求:

  • 该方法接受一个日期作为参数,并返回该日期是否有任何交叉。

  • 如果存在买入信号(5 天移动平均线从下方交叉到上方),则该方法应返回 1

  • 如果存在卖出信号(5 天移动平均线从上方交叉到下方),则该方法应返回-1

  • 如果没有交叉,则该方法返回 0(中性信号

  • 该方法应仅考虑收盘价(该日期的最后更新),而不是该日期的开盘价或中间价

  • 如果该日期没有更新,则该方法应使用前一天的收盘价

  • 如果没有足够的数据来计算长期移动平均数(我们需要至少 11 天的收盘价),则该方法应返回 0

识别代码异味

以下是一个通过测试的实现(要查看测试用例列表,请从github.com/siddhi/test_driven_python下载本章的代码)。该实现使用了datetime模块中的timedelta类,因此您需要在文件顶部导入它才能使其工作。

    def get_crossover_signal(self, on_date):
        cpl = []
        for i in range(11):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(self.price_history):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    cpl.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    cpl.insert(0, price_event)
                    break

        # Return NEUTRAL signal
        if len(cpl) < 11:
            return 0

        # BUY signal
        if sum([update.price for update in cpl[-11:-1]])/10 \
                > sum([update.price for update in cpl[-6:-1]])/5 \
            and sum([update.price for update in cpl[-10:]])/10 \
                < sum([update.price for update in cpl[-5:]])/5:
                    return 1

        # BUY signal
        if sum([update.price for update in cpl[-11:-1]])/10 \
                < sum([update.price for update in cpl[-6:-1]])/5 \
            and sum([update.price for update in cpl[-10:]])/10 \
                > sum([update.price for update in cpl[-5:]])/5:
                    return -1

        # NEUTRAL signal
        return 0

虽然上述代码实现了功能,但它完全无法阅读。这就是在跳过重构步骤时会发生的情况。几个月后回来修复错误或添加功能时,理解它将花费很长时间。因此,定期重构至关重要。

你能在这段代码中找到哪些问题?以下是一些问题:

  • 长方法:长方法和类难以阅读和理解。

  • 不清晰的命名:例如,变量cpl应该代表什么?

  • 复杂的条件语句if条件相当复杂,不清楚它们具体检查了什么。

  • 糟糕的注释:所有注释都没有描述性,而且另外两个注释都说了买入信号。显然,其中一个是错误的。

  • 魔法常数:在多个地方,数字 5、10、-11 等被硬编码。假设我们决定将长期移动平均数改为使用 20 天周期,那么我们需要更改哪些地方?我们可能会遗漏一个更改的可能性有多大?

  • 代码重复:这两个条件似乎几乎相同,只有非常小的差异。

所有这些问题通常被称为代码异味。代码异味是简单且易于发现的模式,可以通过重构来改进代码。有时,通过进行一些简单的更改就可以修正代码异味。有时,它甚至可能导致设计本身的改变。

代码重构

重构是通过一系列非常小的步骤来清理代码或更改设计的过程。在重构过程中不添加或删除任何新功能。重构的目的是通过消除一些代码异味来使代码变得更好。有各种各样的重构,从极其简单的到更复杂的重构。让我们将这些应用于上面的代码。

重命名变量和重命名方法的重构

这两种可能是最简单的重构。名称本身就说明了问题——重构是将变量或方法重命名。虽然简单,但它们非常重要,因为糟糕的变量和方法名称在代码中非常常见。

以下是将重命名变量重构应用于代码的步骤:

  1. 运行所有测试以确保它们通过。

  2. 改变变量的名称,并在所有使用该变量的地方进行更改。

  3. 再次运行所有测试以确保我们没有破坏任何东西。

重命名方法重构遵循以下步骤:

  1. 运行所有测试以确保它们通过。

  2. 改变方法名称,并在所有调用此方法的地方进行更改。

  3. 再次运行所有测试。

现在我们将重命名变量重构应用于我们的代码。cpl变量保存了过去 11 天股票的收盘价列表。我们应该将其重命名为更具描述性的名称,例如closing_price_list。我们现在就来做这件事:

  1. 运行测试(测试用例列表在本书末尾的练习部分给出)。

  2. 在方法的所有地方将cpl重命名为closing_price_list

  3. 再次运行测试。如果有任何地方我们忘记重命名变量,那么测试将失败,我们可以修复它并再次运行测试。

注意

测试作为安全网

在我们进行重构之前,拥有一个坚实的测试集是至关重要的。这是因为测试让我们有信心在重构过程中没有破坏任何东西。在重构过程中,我们将多次运行测试,因为我们从一步到下一步进行。

现在我们将重命名变量重构应用于我们的代码。cpl变量保存了过去 11 天股票的收盘价列表。我们应该将其重命名为更具描述性的名称,例如closing_price_list

快速搜索和替换后,代码现在看起来是这样的:

def get_crossover_signal(self, on_date):
    closing_price_list = []
    for i in range(11):
        chk = on_date.date() - timedelta(i)
        for price_event in reversed(self.price_history):
            if price_event.timestamp.date() > chk:
                pass
            if price_event.timestamp.date() == chk:
                closing_price_list.insert(0, price_event)
                break
            if price_event.timestamp.date() < chk:
                closing_price_list.insert(0, price_event)
                break

    # Return NEUTRAL signal
    if len(closing_price_list) < 11:
        return 0

    # BUY signal
    if (sum([update.price
                 for update in closing_price_list[-11:-1]])/10
            > sum([update.price
                       for update in closing_price_list[-6:-1]])/5
        and sum([update.price
                     for update in closing_price_list[-10:]])/10
            < sum([update.price
                       for update in closing_price_list[-5:]])/5):
                return 1

    # BUY signal
    if (sum([update.price
                 for update in closing_price_list[-11:-1]])/10
            < sum([update.price
                 for update in closing_price_list[-6:-1]])/5
        and sum([update.price
                     for update in closing_price_list[-10:]])/10
            > sum([update.price
                       for update in closing_price_list[-5:]])/5):
                return -1

    # NEUTRAL signal
    return 0

注释风格

接下来,让我们看看方法中的注释。一般来说,注释是代码的坏味道,因为它们表明代码本身不易读。一些注释,如上面的代码中的注释,只是简单地重复了代码正在做什么。我们经常添加这样的注释,因为这样做比清理代码要容易。所以,无论何时我们看到注释,都值得探索是否需要代码清理。注释的另一个问题是它们可以非常容易地与代码不同步。当我们未来回来实现新功能时,我们没有更新注释是很常见的情况。当我们试图理解注释与代码不同步的代码时,这会导致大量的困惑。

并非所有注释都是不好的。有用的注释解释了为什么某个代码片段要以这种方式编写。这是仅通过阅读代码无法推断出的信息。考虑以下示例:

# Halve the price if age is 60 or above
if age >= 60:
    price = price * 0.5

# People aged 60 or above are eligible for senior citizen discount
if age >= 60:
    price = price * 0.5

if age >= SENIOR_CITIZEN_AGE:
    price = price * SENIOR_CITIZEN_DISCOUNT

第一个示例显示了一个仅仅重复下面代码的注释。这里注释没有增加任何价值。粗略地看一下代码,读者就能确切地知道注释在说什么。

第二个示例显示了一个更好的注释。这个注释没有重复代码,而是解释了为什么存在这段特定代码的合理性。

在第三个示例中,硬编码的数字已经被常数替换。在这个例子中,代码是自我解释的,因此我们可以完全去掉注释。

三个示例展示了编写注释的理想过程。首先,我们看看是否可以通过某种方式使代码更清晰,以至于我们不需要注释。如果这不可能,那么就写一个注释,解释为什么代码要以这种方式编写。如果你倾向于写一个关于代码片段做什么的注释,那么请停下来思考是否需要重构代码。

将魔法数字替换为常数

如前一个示例所示,用常数替换硬编码的值可以完成两件事:首先,如果我们需要更改这些值,我们可以在一个地方完成更改,其次,常数更具描述性,有助于使代码更易读。

这个重构的过程如下:

  1. 运行测试。

  2. 创建常数,并用常数替换一个硬编码的值。

  3. 再次运行测试。

  4. 重复步骤 2 和 3,直到所有值都被常数替换。

我们的方法在所有计算中使用长期移动平均和短期移动平均的时间跨度。我们可以创建常数来标识这两个值,如下所示:

class Stock:
    LONG_TERM_TIMESPAN = 10
    SHORT_TERM_TIMESPAN = 5

我们可以使用我们方法中的常数,如下所示:

    def get_crossover_signal(self, on_date):
        closing_price_list = []
        NUM_DAYS = self.LONG_TERM_TIMESPAN + 1
        for i in range(NUM_DAYS):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(self.price_history):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    closing_price_list.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    closing_price_list.insert(0, price_event)
                    break

除了用于计算的常数外,我们还可以用更具描述性的Enum类替换返回值。这是 Python 3.4 中的一个新特性,我们可以在这里使用它。

注意

虽然Enum是 Python 3.4 标准库的一部分,但它也被回滚到更早的 Python 版本。如果你使用的是较旧的 Python 版本,请从 PyPy 下载并安装 enum34 包。

要做到这一点,我们首先按照以下方式导入Enum

from enum import Enum

然后我们创建枚举类。

class StockSignal(Enum):
    buy = 1
    neutral = 0
    sell = -1

最后,我们可以用枚举替换返回值:

        # NEUTRAL signal
        return StockSignal.neutral

通过这个更改,我们还可以删除返回值上面的注释,因为常量已经足够描述性。

提取方法重构

另一种使注释冗余的方法是将代码放入一个具有描述性名称的方法中。这也帮助将长方法分解成更小的、更容易理解的方法。提取方法重构用于此目的。提取方法重构的步骤如下:

  1. 运行现有的测试。

  2. 识别代码块中我们想要重构的变量,这些变量也在代码块之前使用。这些变量需要作为参数传递到我们的方法中。

  3. 识别代码块中在代码块之后使用的变量。这些变量将是我们方法返回的值。

  4. 创建一个具有描述性名称的方法,该方法接受上述变量作为参数。

  5. 使新方法返回代码块之后需要的适当值。

  6. 将代码块移动到方法中。用对方法的调用替换这些行。

  7. 再次运行测试。

让我们将这种重构应用到我们的方法中。这个循环用于为前十一天的每一天创建一个收盘价列表:

    def _get_closing_price_list(self, on_date, num_days):
        closing_price_list = []
        for i in range(num_days):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(self.price_history):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    closing_price_list.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    closing_price_list.insert(0, price_event)
                    break
        return closing_price_list

我们可以将这段代码提取到一个单独的方法中。以下是这样做的方法:

  1. 首先,我们创建一个名为_get_closing_price_list的新方法:

    def _get_closing_price_list(self, on_date, num_days):
        pass
    

    这个方法接受两个参数,因为那些值在循环中使用。目前它们是局部变量,但一旦我们将循环提取到这个方法中,我们就需要将这些值传递给方法。

  2. 现在,我们将循环代码从主方法中剪切并粘贴到这个新方法中:

    def _get_closing_price_list(self, on_date, num_days):
        closing_price_list = []
        for i in range(NUM_DAYS):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(self.price_history):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    closing_price_list.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    closing_price_list.insert(0, price_event)
                    break
    
  3. 到目前为止,循环仍然引用的是NUM_DAYS常量,这是一个局部变量。我们需要将其更改为使用参数的值。我们还将此方法返回closing_price_list

    def _get_closing_price_list(self, on_date, num_days):
        closing_price_list = []
        for i in range(num_days):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(self.price_history):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    closing_price_list.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    closing_price_list.insert(0, price_event)
                    break
        return closing_price_list
    
  4. 最后,我们将调用此方法的地方放在循环代码原本的位置:

    def get_crossover_signal(self, on_date):
        NUM_DAYS = self.LONG_TERM_TIMESPAN + 1
        closing_price_list = \
            self._get_closing_price_list(on_date, NUM_DAYS)
    

现在,我们运行测试以确保我们没有破坏任何东西。它们都应该通过。

重构后的代码看起来像这样:

def _get_closing_price_list(self, on_date, num_days):
    closing_price_list = []
    for i in range(num_days):
        chk = on_date.date() - timedelta(i)
        for price_event in reversed(self.price_history):
            if price_event.timestamp.date() > chk:
                pass
            if price_event.timestamp.date() == chk:
                closing_price_list.insert(0, price_event)
                break
            if price_event.timestamp.date() < chk:
                closing_price_list.insert(0, price_event)
                break
    return closing_price_list

def get_crossover_signal(self, on_date):
    NUM_DAYS = self.LONG_TERM_TIMESPAN + 1
    closing_price_list = \
        self._get_closing_price_list(on_date, NUM_DAYS)
    …

替换计算为临时变量

现在,让我们将注意力转向执行交叉检查的条件语句。

条件语句很混乱,因为我们同时进行许多计算和比较,这很难跟踪。我们可以通过使用临时变量来存储计算值,然后在条件中使用这些变量来清理它。

在这个重构中,我们并没有使用变量来达到任何目的,只是给计算一个名字,从而使代码更容易阅读。

以下是我们如何进行这个重构的:

  1. 运行测试。

  2. 将计算赋值给一个变量。变量的名称应该解释计算的目的。

  3. 在条件中使用变量。

  4. 运行测试。

让我们将条件语句中的四个计算提取到变量中:

long_term_series = closing_price_list[-self.LONG_TERM_TIMESPAN:]
prev_long_term_series = \
    closing_price_list[-self.LONG_TERM_TIMESPAN-1:-1]
short_term_series = closing_price_list[-self.SHORT_TERM_TIMESPAN:]
prev_short_term_series = \
    closing_price_list[-self.SHORT_TERM_TIMESPAN-1:-1]

我们然后可以在条件中使用这些变量:

if sum([update.price for update in prev_long_term_series])/10 \
    > sum([update.price for update in prev_short_term_series])/5 \
    and sum([update.price for update in long_term_series])/10 \
        < sum([update.price for update in short_term_series])/5:
            return StockSignal.buy

提取条件到方法

我们现在可以将注意力转向条件语句。在条件语句中并不清楚正在发生什么比较。一种处理方法是在上面的重构中继续使用用临时变量替换计算。另一种选择是应用将条件提取到方法中的重构。在这个重构中,我们将比较移动到它自己的方法中,并给它一个描述性的名称。

以下是对重构的步骤:

  1. 运行测试。

  2. 将整个条件语句移动到方法中。

  3. 在条件之前调用该方法。

  4. 运行测试。

这是目前我们拥有的条件代码:

if sum([update.price for update in prev_long_term_series])/10 \> sum([update.price for update in prev_short_term_series])/5 \and sum([update.price for update in long_term_series])/10 \< sum([update.price for update in short_term_series])/5:
            return StockSignal.buy

首先,我们应用用临时变量替换计算的重构,并将移动平均计算提取到一个命名变量中:

long_term_ma = sum([update.price
                    for update in long_term_series])\
                /self.LONG_TERM_TIMESPAN
prev_long_term_ma = sum([update.price
                         for update in prev_long_term_series])\
                     /self.LONG_TERM_TIMESPAN
short_term_ma = sum([update.price
                     for update in short_term_series])\
                /self.SHORT_TERM_TIMESPAN
prev_short_term_ma = sum([update.price
                          for update in prev_short_term_series])\
                     /self.SHORT_TERM_TIMESPAN

接下来,可以将条件语句中进行的比较提取到像这样的方法中:

def _is_short_term_crossover_below_to_above(self, prev_short_term_ma,
                                            prev_long_term_ma,
                                            short_term_ma,
                                            long_term_ma):
    return prev_long_term_ma > prev_short_term_ma \
        and long_term_ma < short_term_ma

def _is_short_term_crossover_above_to_below(self, prev_short_term_ma,
                                            prev_long_term_ma,
                                            short_term_ma,
                                            long_term_ma):
    return prev_long_term_ma < prev_short_term_ma \
        and long_term_ma > short_term_ma

我们现在在if语句中调用该方法,将我们的临时变量作为参数传入:

if self._is_short_term_crossover_below_to_above(prev_short_term_ma,
                                                prev_long_term_ma,
                                                short_term_ma,
                                                long_term_ma):
            return StockSignal.buy

if self._is_short_term_crossover_above_to_below(prev_short_term_ma,
                                                prev_long_term_ma,
                                                short_term_ma,
                                                long_term_ma):
            return StockSignal.sell

return StockSignal.neutral

这是经过最后几次重构后的方法看起来像这样:

NUM_DAYS = self.LONG_TERM_TIMESPAN + 1
closing_price_list = self._get_closing_price_list(on_date, NUM_DAYS)

if len(closing_price_list) < NUM_DAYS:
    return StockSignal.neutral

long_term_series = closing_price_list[-self.LONG_TERM_TIMESPAN:]
prev_long_term_series = \
    closing_price_list[-self.LONG_TERM_TIMESPAN-1:-1]
short_term_series = closing_price_list[-self.SHORT_TERM_TIMESPAN:]
prev_short_term_series = \
    closing_price_list[-self.SHORT_TERM_TIMESPAN-1:-1]

long_term_ma = sum([update.price
                    for update in long_term_series])\
                /self.LONG_TERM_TIMESPAN
prev_long_term_ma = sum([update.price
                         for update in prev_long_term_series])\
                     /self.LONG_TERM_TIMESPAN
short_term_ma = sum([update.price
                     for update in short_term_series])\
                /self.SHORT_TERM_TIMESPAN
prev_short_term_ma = sum([update.price
                          for update in prev_short_term_series])\
                     /self.SHORT_TERM_TIMESPAN

if self._is_short_term_crossover_below_to_above(prev_short_term_ma,
                                                prev_long_term_ma,
                                                short_term_ma,
                                                long_term_ma):
            return StockSignal.buy

if self._is_short_term_crossover_above_to_below(prev_short_term_ma,
                                                prev_long_term_ma,
                                                short_term_ma,
                                                long_term_ma):
            return StockSignal.sell

return StockSignal.neutral

DRY 原则

在编写良好代码的重要原则中,DRY 原则是最重要的。DRY代表不要重复自己。如果你发现自己多次(或相似)在多个地方编写相同的代码,那么重构将允许你将这种逻辑放在一个地方,并在需要的地方调用它。这可能只是将代码移动到函数中,并在每个地方调用该函数,或者可能是一个更复杂的重构。

再看看我们刚刚重构的条件语句:

def _is_short_term_crossover_below_to_above(self, prev_short_term_ma,
                                            prev_long_term_ma,
                                            short_term_ma,
                                            long_term_ma):
    return prev_long_term_ma > prev_short_term_ma \
        and long_term_ma < short_term_ma

def _is_short_term_crossover_above_to_below(self, prev_short_term_ma,
                                            prev_long_term_ma,
                                            short_term_ma,
                                            long_term_ma):
    return prev_long_term_ma < prev_short_term_ma \
        and long_term_ma > short_term_ma

我们可以看到它们几乎相同。唯一的区别是比较的顺序相反。我们能否消除这种代码重复?

一种方法是在第一个方法中改变比较器的顺序:

def _is_short_term_crossover_below_to_above(self, prev_short_term_ma,prev_long_term_ma,short_term_ma,long_term_ma):return prev_short_term_ma < prev_long_term_ma \
        and short_term_ma > long_term_ma

除了参数名称外,现在它与第二个方法完全相同:

def _is_short_term_crossover_above_to_below(self, prev_short_term_ma,
                                            prev_long_term_ma,
                                            short_term_ma,
                                            long_term_ma):
    return prev_long_term_ma < prev_short_term_ma \
        and long_term_ma > short_term_ma

我们现在可以将两个方法合并为一个:

def _is_crossover_below_to_above(self, prev_ma, prev_reference_ma,
                                 current_ma, current_reference_ma):
    return prev_ma < prev_reference_ma \
        and current_ma > current_reference_ma

并在两个条件语句中调用这个单一的方法:

if self._is_crossover_below_to_above(prev_short_term_ma,
                                     prev_long_term_ma,
                                     short_term_ma,
                                     long_term_ma):
            return StockSignal.buy

if self._is_crossover_below_to_above(prev_long_term_ma,
                                     prev_short_term_ma,
                                     long_term_ma,
                                     short_term_ma):
            return StockSignal.sell

注意两个调用之间短期和长期参数的顺序是如何交换的。第一个检查短期移动平均线从下向上穿过长期移动平均线。第二个检查长期移动平均线从下向上穿过短期移动平均线——这等同于检查短期从上方穿过下方。通过在两种情况下(从下到上)执行相同的检查并交换参数,我们能够消除代码中的重复。

单一职责原则

到目前为止,我们已经执行了一系列局部重构。这些重构包括将代码移动到或从方法中,将计算拉入变量中等。这些重构提高了代码的可读性,但大多是局部更改,不会影响更大的设计。

从设计角度来看,导致类变得杂乱的最常见原因是没有遵循单一职责原则SRP)。这个原则表明,一个类应该有一个单一、明确、连贯的目的。试图做太多不同事情的课程是设计不佳的指标。

让我们回顾一下Stock类是否符合这一标准。该类的基本职责如下:

  • 为特定股票保存价格更新的历史记录

  • 检查股票是否符合某些条件

此外,该类还执行以下操作:

  • 计算每天的收盘价列表(或更一般地,处理时间序列的代码)

  • 计算不同时间点的移动平均数

后两个责任应该分配给一个单独的类。

提取类

提取类重构用于将一些功能移动到单独的类中。这可能是最常用的设计重构。当我们看到某个类承担了多个责任时,这是一个理想的重构应用场景。

以下是我们想要做的事情:

  • 将所有与时间序列管理相关的代码移动到TimeSeries类中

  • 将所有与移动平均数相关的代码移动到MovingAverage类中

执行提取类重构的步骤如下:

  1. 运行所有测试。

  2. 创建一个新类。

  3. __init__作用域中实例化新类,或者将其作为参数传递。

  4. 将一个方法从源类移动到新类中。如果要移动的代码不在方法中,则首先使用提取方法重构将其提取到局部方法中。

  5. 将所有局部调用更改为调用新类实例中的方法。

  6. 再次运行测试。

  7. 对每个要移动的功能重复步骤 3 到 5。

现在让我们将所有与时间序列相关的功能提取到TimeSeries类中。

首先,我们在stock_alerter目录中创建一个名为timeseries.py的文件。我们将在其中创建我们的类。

接下来,我们将在timeseries.py中创建一个空的TimeSeries类,如下所示:

class TimeSeries:
    pass

到目前为止,我们一直使用 price_history,一个列表,来存储价格历史。我们现在想将所有这些信息存储在我们的 TimeSeries 类中。我们将逐步进行这个过渡。第一步是在 Stock 类中添加一个实例变量,如下所示:

    def __init__(self, symbol):
        self.symbol = symbol
        self.price_history = []
        self.history = TimeSeries()

记得在做出这个更改之前在文件顶部导入 TimeSeries。现在我们可以将更新功能迁移到 TimeSeries 类中,如下所示:

import bisect
import collections

Update = collections.namedtuple("Update", ["timestamp", "value"])

class TimeSeries:
    def __init__(self):
        self.series = []

    def update(self, timestamp, value):
        bisect.insort_left(self.series, Update(timestamp, value))

一旦迁移,我们就在 Stock 类中调用新的方法,如下所示:

    def update(self, timestamp, price):
        if price < 0:
            raise ValueError("price should not be negative")
        bisect.insort_left(self.price_history, PriceEvent(timestamp, price))
        self.history.update(timestamp, price)

注意我们刚刚添加了对 timeseries 的调用,但我们还没有移除更新 self.price_history 的旧调用。这是因为这个列表仍然在其他地方直接使用。通过不立即删除此行,我们不会破坏任何功能。所有测试仍然通过。一旦我们完成迁移,我们将回来删除此行。

现在我们需要更改价格和 is_increasing_trend 方法,以停止使用 self.price_history 并开始使用时间序列类。它们目前看起来是这样的:

def price(self):
    return self.price_history[-1].price \
        if self.price_history else None

def is_increasing_trend(self):
    return self.price_history[-3].price < \
        self.price_history[-2].price < self.price_history[-1].price

我们下一步是向 TimeSeries 添加一个字典访问方法:

class TimeSeries:
    def __getitem__(self, index):
        return self.series[index]

这使我们能够将 Stock.priceStock.is_increasing_trend 方法更改为使用 TimeSeries 类而不是访问 self.price_history

    def price(self):
        try:
           return self.history[-1].value
        except IndexError:
            return None

    def is_increasing_trend(self):
        return self.history[-3].value < \
            self.history[-2].value < self.history[-1].value

我们应该再次运行测试,以检查 Stock.priceStock.is_increasing_trend 的新实现是否仍然按预期工作。所有 21 个测试都应该仍然通过。

将方法移动到类中

self.price_history 被使用的一个最终位置,是在 _get_closing_price_list 方法中。我们不是替换 self.price_history 的使用,而是将整个方法移动到 TimeSeries 类中。这是 将方法移动到类中 的重构。

为了进行这个重构,我们将执行以下操作:

  1. 运行测试。

  2. 将方法移动到目标类。如果方法使用任何实例变量,那么我们需要将它们添加到参数列表中。

  3. 替换所有调用以使用其他类中的方法,并添加任何需要传递的新参数。

  4. 一些调用者可能没有目标类的引用。在这种情况下,我们需要在 __init__ 范围内实例化对象,或者将其引用作为参数传递。

  5. 再次运行测试。

通常,在这个重构的末尾,我们需要在目标类中进行一些进一步的局部重构。因此,一些额外添加的参数可能需要移动到其他地方或进行更改。一些参数可能被添加到初始化器中,调用者相应地进行修改。

以下示例将使这一点更加清晰。让我们首先将 _get_closing_price_list 方法移动到 TimeSeries 类中。由于这将是新类中的一个公共方法,我们可以从名称中删除初始的下划线。

class TimeSeries:
    def get_closing_price_list(self, on_date, num_days, price_history):
        closing_price_list = []
        for i in range(num_days):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(price_history):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    closing_price_list.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    closing_price_list.insert(0, price_event)
                    break
        return closing_price_list

注意我们添加到这个方法中的额外price_history参数。原始方法使用了self.price_history变量。由于这是Stock类的实例变量,它不在TimeSeries类中可用。为了解决这个问题,我们传入price_history作为参数并在方法中使用它。

来自Stock类的调用现在如下所示:

    def get_crossover_signal(self, on_date):
        NUM_DAYS = self.LONG_TERM_TIMESPAN + 1
        closing_price_list = self.history.get_closing_price_list(on_date, NUM_DAYS, self.price_history)

我们在这个时候运行测试以验证所有测试是否仍在通过。

一旦我们验证测试通过,我们现在可以回过头来删除我们添加的额外参数。TimeSeries类有自己的实例变量self.series,其中包含价格历史。我们可以在方法中使用这个变量并删除额外参数。现在该方法如下:

    def get_closing_price_list(self, on_date, num_days):
        closing_price_list = []
        for i in range(num_days):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(self.series):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    closing_price_list.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    closing_price_list.insert(0, price_event)
                    break
        return closing_price_list

调用如下所示:

    def get_crossover_signal(self, on_date):
        NUM_DAYS = self.LONG_TERM_TIMESPAN + 1
        closing_price_list = self.history.get_closing_price_list(on_date, NUM_DAYS)

再次运行测试以检查一切是否正常,如下所示:

==================================================================
ERROR: test_with_upward_crossover_returns_buy (stock_alerter.stock.StockCrossOverSignalTest)
------------------------------------------------------------------
Traceback (most recent call last):
 File "c:\Projects\tdd_with_python\src\stock_alerter\stock.py", line 239, in test_with_upward_crossover_returns_buy
 self.goog.get_crossover_signal(date_to_check))
 File "c:\Projects\tdd_with_python\src\stock_alerter\stock.py", line 63, in get_crossover_signal
 for update in long_term_series])\
 File "c:\Projects\tdd_with_python\src\stock_alerter\stock.py", line 63, in <listcomp>
 for update in long_term_series])\
AttributeError: 'Update' object has no attribute 'price'

Ran 21 tests in 0.018s

FAILED (errors=7)

哎呀!看起来有些测试失败了!

问题在于存储在self.price_history中的更新使用price属性来引用价格,但timeseries模块将其称为 value。因此,我们需要更改计算移动平均的地方,并将价格替换为 value。有了这个更改,测试再次通过,我们的移动平均计算现在如下所示:

        long_term_ma = sum([update.value
                            for update in long_term_series])\
                        /self.LONG_TERM_TIMESPAN
        prev_long_term_ma = sum([update.value
                                 for update in prev_long_term_series])\
                             /self.LONG_TERM_TIMESPAN
        short_term_ma = sum([update.value
                             for update in short_term_series])\
                        /self.SHORT_TERM_TIMESPAN
        prev_short_term_ma = sum([update.value
                                  for update in prev_short_term_series])\
                             /self.SHORT_TERM_TIMESPAN

上述代码与之前相同,只是我们现在使用update.value而不是update.price

现在,price_historyStock类中不再被使用,因此我们可以将其从类中删除。我们还可以删除名为PriceEvent的元组以及任何未使用的导入。这些更改后的初始化器和更新方法如下:

class Stock:
    LONG_TERM_TIMESPAN = 10
    SHORT_TERM_TIMESPAN = 5

    def __init__(self, symbol):
        self.symbol = symbol
        self.history = TimeSeries()

    def update(self, timestamp, price):
        if price < 0:
            raise ValueError("price should not be negative")
        self.history.update(timestamp, price)

通过这个更改,我们的提取类重构已经完成。

测试的重要性

提取类重构显示了拥有一个好的单元测试套件以及在重构期间频繁运行它的重要性。在移动代码时很容易忽略小事,这可能会导致代码损坏。通过经常运行测试,我们可以立即知道我们破坏了什么。这使得修复错误变得容易。如果我们测试之前就完成了整个重构,那么我们就不清楚重构的哪个步骤破坏了测试,我们就必须回过头来调试整个重构。

我们还需要做的一件事是在重构完成后调整测试。在一些重构中,例如提取类(Extract Class),我们可能会发现我们还需要将测试移动到新的类中。例如,如果我们有任何针对_get_closing_price_list方法的测试,那么我们会将这些测试移动到TimeSeries类中。在这种情况下,由于该方法不是公开的,我们没有为它们编写测试,也没有任何东西可以移动。

重构后,该方法已成为TimeSeries类的一个公开方法,目前还没有任何测试。回过头来为该方法编写一些测试是个好主意。

练习

正如我们将时间序列代码提取到其自己的类中一样,我们也可以将移动平均代码提取到单独的类中。尝试作为练习进行这个重构。完成后,查看附录以了解一个可能的解决方案的概述。

总结

下面是计算 DMAC 的伪代码算法:

  1. 计算短期和长期移动平均。

  2. 如果短期移动平均从底部穿过长期移动平均到顶部,那么买入

  3. 如果长期移动平均从底部穿过短期移动平均到顶部,那么卖出

  4. 否则什么也不做。

这是我们的起始代码,它通过了所有测试:

def get_crossover_signal(self, on_date):
    cpl = []
    for i in range(11):
        chk = on_date.date() - timedelta(i)
        for price_event in reversed(self.price_history):
            if price_event.timestamp.date() > chk:
                pass
            if price_event.timestamp.date() == chk:
                cpl.insert(0, price_event)
                break
            if price_event.timestamp.date() < chk:
                cpl.insert(0, price_event)
                break

    # Return NEUTRAL signal
    if len(cpl) < 11:
        return 0

    # BUY signal
    if sum([update.price for update in cpl[-11:-1]])/10 \
            > sum([update.price for update in cpl[-6:-1]])/5 \
        and sum([update.price for update in cpl[-10:]])/10 \
            < sum([update.price for update in cpl[-5:]])/5:
                return 1

    # BUY signal
    if sum([update.price for update in cpl[-11:-1]])/10 \
            < sum([update.price for update in cpl[-6:-1]])/5 \
        and sum([update.price for update in cpl[-10:]])/10 \
            > sum([update.price for update in cpl[-5:]])/5:
                return -1

    # NEUTRAL signal
    return 0

在将移动平均代码提取到其自己的类中(参见上面的练习尝试这样做,或查看附录 A,练习答案以查看我们如何到达这里的其中一个解决方案)之后,这是get_crossover_signal方法的样子:

def get_crossover_signal(self, on_date):
    long_term_ma = MovingAverage(self.history, self.LONG_TERM_TIMESPAN)
    short_term_ma = MovingAverage(self.history, self.SHORT_TERM_TIMESPAN)

    try:
        if self._is_crossover_below_to_above(
                on_date,
                short_term_ma,
                long_term_ma):
            return StockSignal.buy

        if self._is_crossover_below_to_above(
                on_date,
                long_term_ma,
                short_term_ma):
            return StockSignal.sell
    except NotEnoughDataException:
        return StockSignal.neutral

    return StockSignal.neutral

差异很明显。重构后的代码就像上面的伪代码一样,几乎是一对一对应。了解算法的人会立刻知道这个方法在做什么。我们不需要写一行注释来使它可读。我们无法对我们开始时的代码说同样的话。

新代码只有 9 条语句长,并将所有非核心功能委托给TimeSeriesMovingAverage类。这些类本身相当短,易于理解。总的来说,重构大大提高了代码的质量。

最好的部分是什么?我们进行了小的改动,并且始终将测试作为安全网,以确保我们没有破坏任何东西。没有测试,我们无法承担这些重构——破坏代码的风险太大。实际上,在编写本章中您看到的代码时,我多次破坏了测试。幸运的是,测试在那里,错误在几分钟内就被修复了。

一个人可能会问,我们迄今为止所进行的所有重构所需的时间。本章看起来相当庞大且令人畏惧,但一旦我们熟悉了这些技术,完成所有这些重构只需要大约 30 到 60 分钟。

摘要

在本章中,您查看了一些最常见的代码恶臭以及修复它们的常见重构。您一步一步地看到了如何在我们的项目中执行每个重构,以及一个好的测试套件如何使我们能够自信地执行这些重构。测试驱动开发和重构是相辅相成的,并且是任何开发者工具箱中的无价之宝。在下一章中,我们将探讨使用模拟对象测试代码交互。

第四章:使用模拟对象测试交互

在查看RuleStock类之后,现在让我们将注意力转向Event类。Event类非常简单:接收者可以注册事件,以便在事件发生时收到通知。当事件触发时,所有接收者都会收到事件的通知。

更详细的描述如下:

  • 事件类有一个connect方法,该方法在事件触发时调用一个方法或函数

  • 当调用fire方法时,所有注册的回调函数都会用传递给fire方法的相同参数被调用

connect方法编写测试相当直接——我们只需检查接收者是否被正确存储。但是,我们如何编写fire方法的测试?此方法不会更改任何状态或存储任何我们可以断言的值。此方法的主要责任是调用其他方法。我们如何测试这是否被正确执行?

这就是模拟对象出现的地方。与断言对象状态的普通单元测试不同,模拟对象用于测试多个对象之间的交互是否按预期发生。

手写一个简单的模拟

首先,让我们看看Event类的代码,以便我们了解测试需要做什么。以下代码位于源目录中的event.py文件中:

class Event:
    """A generic class that provides signal/slot functionality"""

    def __init__(self):
        self.listeners = []

    def connect(self, listener):
        self.listeners.append(listener)

    def fire(self, *args, **kwargs):
        for listener in self.listeners:
            listener(*args, **kwargs)

这段代码的工作方式相当简单。希望收到事件通知的类应该调用connect方法并传递一个函数。这将注册该函数用于事件。然后,当使用fire方法触发事件时,所有注册的函数都会收到事件的通知。以下是如何使用此类的一个概述:

>>> def handle_event(num):
...   print("I got number {0}".format(num))
...
>>> event = Event()
>>> event.connect(handle_event)
>>> event.fire(3)
I got number 3
>>> event.fire(10)
I got number 10

如您所见,每次调用fire方法时,所有使用connect方法注册的函数都会用给定的参数被调用。

那么,我们如何测试fire方法?上面的概述提供了一个提示。我们需要做的是创建一个函数,使用connect方法注册它,然后验证当调用fire方法时该方法是否被通知。以下是一种编写此类测试的方法:

import unittest
from ..event import Event

class EventTest(unittest.TestCase):
    def test_a_listener_is_notified_when_an_event_is_raised(self):
        called = False
        def listener():
            nonlocal called
            called = True

        event = Event()
        event.connect(listener)
        event.fire()
        self.assertTrue(called)

将此代码放入测试文件夹中的test_event.py文件,并运行测试。测试应该通过。以下是我们所做的工作:

  1. 首先,我们创建一个名为called的变量并将其设置为False

  2. 接下来,我们创建一个虚拟函数。当函数被调用时,它将called设置为True

  3. 最后,我们将虚拟函数连接到事件并触发事件。

  4. 如果在事件触发时成功调用了虚拟函数,则called变量将被更改为True,我们断言该变量确实是我们预期的。

我们上面创建的虚拟函数是一个模拟的例子。模拟简单地说是一个在测试用例中替代真实对象的对象。模拟会记录一些信息,例如它是否被调用,传入了哪些参数等,然后我们可以断言模拟按预期被调用。

谈到参数,我们应该编写一个测试来检查参数是否被正确传入。以下是一个这样的测试:

    def test_a_listener_is_passed_right_parameters(self):
        params = ()
        def listener(*args, **kwargs):
            nonlocal params
            params = (args, kwargs)
        event = Event()
        event.connect(listener)
        event.fire(5, shape="square")
        self.assertEquals(((5, ), {"shape":"square"}), params)

这个测试与上一个测试相同,只是它保存了参数,然后在断言中用来验证它们是否正确传入。

到目前为止,我们可以看到我们在设置模拟函数和保存调用信息的方式上出现了一些重复。我们可以将这段代码提取到一个单独的类中,如下所示:

class Mock:
    def __init__(self):
        self.called = False
        self.params = ()

    def __call__(self, *args, **kwargs):
        self.called = True
        self.params = (args, kwargs)

一旦我们这样做,我们就可以在我们的测试中使用Mock类,如下所示:

class EventTest(unittest.TestCase):
    def test_a_listener_is_notified_when_an_event_is_raised(self):
        listener = Mock()
        event = Event()
        event.connect(listener)
        event.fire()
        self.assertTrue(listener.called)

    def test_a_listener_is_passed_right_parameters(self):
        listener = Mock()
        event = Event()
        event.connect(listener)
        event.fire(5, shape="square")
        self.assertEquals(((5, ), {"shape": "square"}), listener.params)

我们刚刚做的是创建了一个简单的模拟类,它相当轻量级,适用于简单用途。然而,我们经常需要更高级的功能,比如模拟一系列调用或检查特定调用的顺序。幸运的是,Python 通过标准库提供的unittest.mock模块为我们提供了支持。

使用 Python 模拟框架

Python 提供的unittest.mock模块是一个非常强大的模拟框架,同时它也非常容易使用。

让我们使用这个库重做我们的测试。首先,我们需要在文件顶部导入mock模块,如下所示:

from unittest import mock

接下来,我们将我们的第一个测试重写如下:

class EventTest(unittest.TestCase):
    def test_a_listener_is_notified_when_an_event_is_raised(self):
        listener = mock.Mock()
        event = Event()
        event.connect(listener)
        event.fire()
        self.assertTrue(listener.called)

我们所做的唯一改变是将我们自己的自定义Mock类替换为 Python 提供的mock.Mock类。就是这样。通过这一行更改,我们的测试现在正在使用内置的模拟类。

unittest.mock.Mock类是 Python 模拟框架的核心。我们只需要实例化这个类,并将其传递到需要的地方。模拟将在called实例变量中记录是否被调用。

我们如何检查是否传入了正确的参数?让我们看看第二个测试的重写如下:

    def test_a_listener_is_passed_right_parameters(self):
        listener = mock.Mock()
        event = Event()
        event.connect(listener)
        event.fire(5, shape="square")
        listener.assert_called_with(5, shape="square")

模拟对象会自动记录传入的参数。我们可以通过在mock对象上使用assert_called_with方法来断言参数。如果参数与预期不符,该方法将引发断言错误。如果我们对测试参数不感兴趣(可能我们只想检查方法是否被调用),则可以传递mock.ANY值。这个值将匹配任何传入的参数。

注意

与模拟上的断言相比,调用正常断言的方式有细微的差别。正常断言定义为unittest.Testcase类的一部分。由于我们的测试继承自该类,我们通过 self 调用断言,例如self.assertEquals。另一方面,模拟断言方法属于mock对象的一部分,所以你通过模拟对象调用它们,例如listener.assert_called_with

模拟对象默认有四个断言可用:

  • assert_called_with:此方法断言最后一次调用是用给定的参数进行的

  • assert_called_once_with:这个断言检查方法是否恰好一次被调用,并且带有给定的参数

  • assert_any_call:这个方法检查在执行过程中是否在某个时刻调用了给定的调用

  • assert_has_calls:这个断言检查是否发生了一系列调用

四个断言非常微妙地不同,当模拟被多次调用时就会显现出来。assert_called_with方法只检查最后一次调用,所以如果有多次调用,则之前的调用将不会被断言。assert_any_call方法将检查在执行过程中是否发生了具有给定参数的调用。assert_called_once_with断言断言单个调用,所以如果模拟在执行过程中被多次调用,则此断言将失败。assert_has_calls断言可以用来断言具有给定参数的一组调用发生了。请注意,断言中检查的调用可能比我们检查的更多,但只要给定的调用存在,断言仍然会通过。

让我们更仔细地看看assert_has_calls断言。以下是我们可以使用此断言编写的相同测试:

    def test_a_listener_is_passed_right_parameters(self):
        listener = mock.Mock()
        event = Event()
        event.connect(listener)
        event.fire(5, shape="square")
        listener.assert_has_calls([mock.call(5, shape="square")])

模拟框架内部使用_Call对象来记录调用。mock.call函数是一个创建这些对象的辅助工具。我们只需用预期的参数调用它来创建所需的调用对象。然后我们可以使用这些对象在assert_has_calls断言中,以断言预期的调用发生了。

当模拟被多次调用时,此方法很有用,我们只想断言一些调用。

模拟对象

在测试Event类时,我们只需要模拟单个函数。模拟的更常见用途是模拟一个类。

注意

本章的其余部分基于代码包中的test_driven_python-CHAPTER4_PART2。您可以从github.com/siddhi/test_driven_python/archive/CHAPTER4_PART2.zip下载。

以下是对Alert类实现的查看:

class Alert:
    """Maps a Rule to an Action, and triggers the action if the rule
    matches on any stock update"""

    def __init__(self, description, rule, action):
        self.description = description
        self.rule = rule
        self.action = action

    def connect(self, exchange):
        self.exchange = exchange
        dependent_stocks = self.rule.depends_on()
        for stock in dependent_stocks:
            exchange[stock].updated.connect(self.check_rule)

    def check_rule(self, stock):
        if self.rule.matches(self.exchange):
            self.action.execute(self.description)

让我们按以下方式分解这个类的工作原理:

  • Alert类在初始化器中接受一个Rule和一个Action

  • 当调用connect方法时,它获取所有依赖的股票并将它们连接到它们的updated事件。

  • updated事件是我们之前看到的Event类的一个实例。每个Stock类都有一个此事件的实例,并且每当对那个股票进行新更新时,它就会被触发。

  • 此事件的监听器是Alert类的self.check_rule方法。

  • 在这种方法中,警报检查新更新是否导致匹配了规则。

  • 如果规则匹配,它会在Action上调用执行方法。否则,不会发生任何操作。

该类有一些要求,如下所示,需要满足。这些要求中的每一个都需要被制作成一个单元测试。

  • 如果股票被更新,类应该检查规则是否匹配

  • 如果规则匹配,则应该执行相应的操作

  • 如果规则不匹配,则不会发生任何操作

我们可以以多种不同的方式测试这一点;让我们来看看一些选项。

第一个选项是完全不使用模拟。我们可以创建一个规则,将其连接到测试操作,然后更新库存并验证操作是否已执行。以下是一个这样的测试示例:

import unittest
from datetime import datetime
from unittest import mock

from ..alert import Alert
from ..rule import PriceRule
from ..stock import Stock

class TestAction:
    executed = False

    def execute(self, description):
        self.executed = True

class AlertTest(unittest.TestCase):
    def test_action_is_executed_when_rule_matches(self):
        exchange = {"GOOG": Stock("GOOG")}
        rule = PriceRule("GOOG", lambda stock: stock.price > 10)
        action = TestAction()
        alert = Alert("sample alert", rule, action)
        alert.connect(exchange)
        exchange["GOOG"].update(datetime(2014, 2, 10), 11)
        self.assertTrue(action.executed)

这是最直接的选择,但需要一些代码来设置,并且我们需要为测试用例创建一个TestAction

我们可以不用创建测试动作,而是用模拟动作来替换它。然后我们可以简单地断言模拟动作已被执行。以下代码展示了这种测试用例的变体:

    def test_action_is_executed_when_rule_matches(self):
        exchange = {"GOOG": Stock("GOOG")}
        rule = PriceRule("GOOG", lambda stock: stock.price > 10)
        action = mock.MagicMock()
        alert = Alert("sample alert", rule, action)
        alert.connect(exchange)
        exchange["GOOG"].update(datetime(2014, 2, 10), 11)
        action.execute.assert_called_with("sample alert")

关于这个测试的一些观察:

如果你注意到,警报不是我们迄今为止常用的普通Mock对象,而是一个MagicMock对象。MagicMock对象就像一个Mock对象,但它对 Python 上所有类都存在的特殊方法提供了支持,例如__str__hasattr。如果我们不使用MagicMock,当代码使用这些方法中的任何一种时,我们有时可能会遇到错误或奇怪的行为。以下示例说明了这种差异:

>>> from unittest import mock
>>> mock_1 = mock.Mock()
>>> mock_2 = mock.MagicMock()
>>> len(mock_1)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: object of type 'Mock' has no len()
>>> len(mock_2)
0
>>>

通常,我们将在需要模拟类的大多数地方使用MagicMock。当我们需要模拟独立函数,或者在罕见情况下我们明确不想为魔法方法提供默认实现时,使用Mock是一个好选择。

关于这个测试的另一个观察点是处理方法的方式。在上面的测试中,我们创建了一个模拟动作对象,但我们在任何地方都没有指定这个模拟类应该包含一个execute方法以及它的行为方式。实际上,我们不需要这样做。当在模拟对象上访问方法或属性时,Python 会方便地创建一个模拟方法并将其添加到模拟类中。因此,当Alert类在我们的模拟动作对象上调用execute方法时,该方法就被添加到我们的模拟动作中。然后我们可以通过断言action.execute.called来检查该方法是否被调用。

Python 在访问时自动创建模拟方法的特性有一个缺点,那就是打字错误或接口更改可能不会被察觉。

例如,假设我们将所有 Action 类中的 execute 方法重命名为 run。但如果我们运行测试用例,它仍然通过。为什么它会通过?因为 Alert 类调用了 execute 方法,而测试只检查 execute 方法是否被调用,它确实被调用了。测试不知道在所有真实的 Action 实现中方法名称已被更改,并且当与实际操作集成时,Alert 类将无法工作。

为了避免这个问题,Python 支持使用另一个类或对象作为规范。当提供规范时,模拟对象只创建规范中存在的那些方法。所有其他方法或属性访问将引发错误。

规范是通过初始化时的 spec 参数传递给模拟的。MockMagicMock 类都支持设置规范。以下代码示例显示了设置 spec 参数与默认 Mock 对象之间的差异:

>>> from unittest import mock
>>> class PrintAction:
...     def run(self, description):
...         print("{0} was executed".format(description))
...

>>> mock_1 = mock.Mock()
>>> mock_1.execute("sample alert") # Does not give an error
<Mock name='mock.execute()' id='54481752'>

>>> mock_2 = mock.Mock(spec=PrintAction)
>>> mock_2.execute("sample alert") # Gives an error
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "C:\Python34\lib\unittest\mock.py", line 557, in __getattr__
 raise AttributeError("Mock object has no attribute %r" % name)
AttributeError: Mock object has no attribute 'execute'

注意在上述示例中,mock_1 在没有错误的情况下执行了 execute 方法,尽管在 PrintAction 中该方法已被重命名。另一方面,通过提供一个规范,对不存在的 execute 方法的调用将引发异常。

模拟返回值

上面的第二个变体展示了我们如何在测试中使用模拟的 Action 类而不是真实的一个。同样,我们也可以在测试中使用模拟规则而不是创建 PriceRule。警报调用规则以查看新的库存更新是否导致规则匹配。警报的行为取决于规则返回 True 还是 False

我们迄今为止创建的所有模拟都不需要返回值。我们只对是否调用了正确的调用感兴趣。如果我们模拟规则,那么我们必须配置它以返回测试的正确值。幸运的是,Python 使这变得非常简单。

我们所需要做的就是将返回值作为参数设置在模拟对象的构造函数中,如下所示:

>>> matches = mock.Mock(return_value=True)
>>> matches()
True
>>> matches(4)
True
>>> matches(4, "abcd")
True

如上所示,模拟只是盲目地返回设置的值,不考虑参数。甚至不考虑参数的类型或数量。我们可以使用相同的程序来设置模拟对象中方法的返回值,如下所示:

>>> rule = mock.MagicMock()
>>> rule.matches = mock.Mock(return_value=True)
>>> rule.matches()
True
>>>

设置返回值还有另一种方法,当处理模拟对象中的方法时非常方便。每个模拟对象都有一个 return_value 属性。我们只需将此属性设置为返回值,每次调用模拟对象都会返回该值,如下所示:

>>> from unittest import mock
>>> rule = mock.MagicMock()
>>> rule.matches.return_value = True
>>> rule.matches()
True
>>>

在上述示例中,当我们访问 rule.matches 时,Python 会自动创建一个模拟的 matches 对象并将其放入 rule 对象中。这允许我们直接在一条语句中设置返回值,而无需为 matches 方法创建模拟。

现在我们已经看到了如何设置返回值,我们可以继续更改我们的测试,使用模拟规则对象,如下所示:

    def test_action_is_executed_when_rule_matches(self):
        exchange = {"GOOG": Stock("GOOG")}
        rule = mock.MagicMock(spec=PriceRule)
        rule.matches.return_value = True
        rule.depends_on.return_value = {"GOOG"}
        action = mock.MagicMock()
        alert = Alert("sample alert", rule, action)
        alert.connect(exchange)
        exchange["GOOG"].update(datetime(2014, 2, 10), 11)
        action.execute.assert_called_with("sample alert")

Alert 对规则进行了两次调用:一次是 depends_on 方法,另一次是 matches 方法。我们为这两个方法都设置了返回值,并且测试通过了。

注意

如果没有为调用显式设置返回值,则默认返回值是返回一个新的模拟对象。对于每个被调用的方法,模拟对象都不同,但对于特定方法是一致的。这意味着如果多次调用相同的方法,每次都会返回相同的模拟对象。

模拟副作用

最后,我们来到 Stock 类。这是 Alert 类的最终依赖项。我们目前在测试中创建 Stock 对象,但我们可以像对 ActionPriceRule 类所做的那样,用模拟对象替换它。

Stock 类在行为上与其他两个模拟对象略有不同。update 方法不仅仅返回一个值——在这个测试中,其主要行为是触发更新事件。只有当这个事件被触发时,规则检查才会发生。

为了做到这一点,我们必须告诉我们的模拟股票类在调用 update 事件时触发事件。模拟对象有一个 side_effect 属性,使我们能够做到这一点。

我们可能有多种原因想要设置副作用。以下是一些原因:

  • 我们可能想要调用另一个方法,比如在 Stock 类的例子中,当调用 update 方法时需要触发事件。

  • 为了引发异常:这在测试错误情况时特别有用。一些错误,如网络超时,可能很难模拟,使用仅引发适当异常的模拟进行测试会更好。

  • 为了返回多个值:这些值可能每次调用模拟时都不同,或者根据传递的参数返回特定的值。

设置副作用就像设置返回值一样。唯一的区别是副作用是一个 lambda 函数。当模拟执行时,参数会被传递给 lambda 函数,然后执行 lambda。以下是我们如何使用模拟的 Stock 类来做到这一点:

    def test_action_is_executed_when_rule_matches(self):
        goog = mock.MagicMock(spec=Stock)
        goog.updated = Event()
        goog.update.side_effect = lambda date, value:
                goog.updated.fire(self)
        exchange = {"GOOG": goog}
        rule = mock.MagicMock(spec=PriceRule)
        rule.matches.return_value = True
        rule.depends_on.return_value = {"GOOG"}
        action = mock.MagicMock()
        alert = Alert("sample alert", rule, action)
        alert.connect(exchange)
        exchange["GOOG"].update(datetime(2014, 2, 10), 11)
        action.execute.assert_called_with("sample alert")

那么,那个测试中发生了什么?

  1. 首先,我们创建 Stock 类的模拟而不是使用真实的一个。

  2. 接下来,我们添加了 updated 事件。我们需要这样做,因为 Stock 类在 __init__ 范围内运行时创建属性。因为属性是动态设置的,MagicMock 不会从 spec 参数中获取属性。我们在这里设置了一个实际的 Event 对象。我们也可以将其设置为模拟,但这可能有些过度。

  3. 最后,我们在模拟股票对象中设置了update方法的副作用。lambda 函数接受方法所需的两个参数。在这个特定的例子中,我们只想触发事件,所以参数在 lambda 函数中没有使用。在其他情况下,我们可能希望根据参数的值执行不同的操作。设置side_effect属性允许我们做到这一点。

就像return_value属性一样,side_effect属性也可以在构造函数中设置。

运行测试,它应该通过。

side_effect属性也可以设置为异常或列表。如果设置为异常,那么在调用模拟时将抛出给定的异常,如下所示:

>>> m = mock.Mock()
>>> m.side_effect = Exception()
>>> m()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "C:\Python34\lib\unittest\mock.py", line 885, in __call__
 return _mock_self._mock_call(*args, **kwargs)
 File "C:\Python34\lib\unittest\mock.py", line 941, in _mock_call
 raise effect
Exception

如果将其设置为列表,那么每次调用模拟时,模拟将返回列表的下一个元素。这是一种模拟每次调用都要返回不同值的函数的好方法,如下所示:

>>> m = mock.Mock()
>>> m.side_effect = [1, 2, 3]
>>> m()
1
>>> m()
2
>>> m()
3
>>> m()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "C:\Python34\lib\unittest\mock.py", line 885, in __call__
 return _mock_self._mock_call(*args, **kwargs)
 File "C:\Python34\lib\unittest\mock.py", line 944, in _mock_call
 result = next(effect)
StopIteration

正如我们所见,模拟框架通过使用side_effect属性来处理副作用的方法非常简单,但非常强大。

模拟过多怎么办?

在前面的几个部分中,我们看到了使用不同级别的模拟编写的相同测试。我们从一个完全不使用任何模拟的测试开始,然后逐个模拟每个依赖项。这些解决方案中哪一个最好?

就像许多事情一样,这是一个个人偏好的问题。一个纯粹主义者可能会选择模拟所有依赖项。我个人的偏好是在对象小且自包含时使用真实对象。我不会模拟Stock类。这是因为模拟通常需要与返回值或副作用进行一些配置,这种配置可能会使测试变得杂乱无章,并使其可读性降低。对于小而自包含的类,直接使用真实对象会更简单。

在另一端,可能与外部系统交互的类、占用大量内存或运行缓慢的类是模拟的好候选。此外,需要大量其他对象来初始化的对象也是模拟的候选。使用模拟,你只需创建一个对象,传递它,并断言你感兴趣检查的部分。你不需要创建一个完全有效的对象。

即使在这里,也有模拟的替代方案。例如,当处理数据库时,通常会将数据库调用模拟出来,并将返回值硬编码到模拟中。这是因为数据库可能位于另一台服务器上,访问它会使测试变慢且不可靠。然而,而不是使用模拟,另一个选择可能是为测试使用快速内存数据库。这允许我们使用实时数据库而不是模拟数据库。哪种方法更好取决于具体情况。

模拟与存根与伪造与间谍之间的比较

到目前为止,我们一直在谈论模拟,但在术语上我们有点宽松。从技术上讲,我们谈论的所有内容都属于测试双倍的范畴。测试双倍是我们用来在测试用例中代替真实对象的某种假对象。

模拟是一种特定的测试双倍,它会记录对它的调用信息,这样我们就可以稍后对它们进行断言。

存根只是一个空的不做任何事情的对象或方法。当我们对测试中的某些功能不关心时,我们会使用它们。例如,假设我们有一个执行计算然后发送电子邮件的方法。如果我们正在测试计算逻辑,我们可能会在测试用例中将电子邮件发送方法替换为一个空的不做任何事情的方法,这样在测试运行时就不会发送任何电子邮件。

伪造是用一个更简单的对象或系统替换一个对象或系统,以简化测试。使用内存数据库而不是真实数据库,或者我们在本章前面创建的TestAction的示例,都是伪造的例子。

最后,间谍是类似于中间人的对象。像模拟一样,它们记录调用以便我们稍后可以对它们进行断言,但在记录之后,它们继续执行原始代码。与另外三个不同,间谍不会替换任何功能。在记录调用后,真实代码仍然被执行。间谍位于中间,不会导致执行模式发生变化。

方法修补

到目前为止,我们已经探讨了简单的模拟模式。这些是在大多数情况下你会使用的的方法。Python 的模拟框架并没有止步于此,它对更复杂的事情提供了巨大的支持。

让我们看看PrintAction类(将此代码放入stock_alerter目录下的action.py文件中)如下:

class PrintAction:
    def execute(self, content):
        print(content)

这是一个简单的动作,当调用execute方法时,它只会将警报描述打印到屏幕上。

现在,我们如何进行测试呢?我们想要测试的是动作实际上以正确的参数调用打印方法。在先前的例子中,我们可以创建一个模拟对象并将其传递到类中,而不是传递一个真实对象。在这里,没有参数或属性我们可以简单地用模拟对象替换。

解决这个问题的方法是使用修补。修补是一种用模拟版本替换全局命名空间中的类或函数的方法。因为 Python 允许动态访问全局以及所有导入的模块,我们可以直接进入并更改标识符指向的对象。

在下面的序列中,你可以看到我们如何用另一个接受一个参数并返回双倍的函数替换print函数:

>>> # the builtin print function prints a string
>>> print("hello") 
hello

>>> # the builtin print function handles multiple parameters
>>> print(1, 2) 
1 2

>>> # this is where the print function is mapped
>>> __builtins__.print 
<built-in function print>

>>> # make the builtin print point to our own lambda
>>> __builtins__.print = lambda x: x*2 

>>> # calling print now executes our substituted function
>>> print("hello") 
'hellohello'

>>> # our lambda does not support two parameters
>>> print(1, 2) Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: <lambda>() takes 1 positional argument but 2 were given

如上图所示,现在所有的print调用都调用我们自己的函数,而不是默认的打印实现。

这给我们提供了进行模拟所需的提示。如果我们只是在运行测试之前将print替换为模拟,会怎样?这样代码最终会执行我们的模拟而不是默认的打印实现,然后我们可以在模拟上断言它是否以正确的参数被调用。

以下是这个技术的示例:

import unittest
from unittest import mock
from ..action import PrintAction

class PrintActionTest(unittest.TestCase):
    def test_executing_action_prints_message(self):
        mock_print = mock.Mock()
        old_print = __builtins__["print"]
        __builtins__["print"] = mock_print
        try:
            action = PrintAction()
            action.execute("GOOG > $10")
            mock_print.assert_called_with("GOOG > $10")
        finally:
            __builtins__["print"] = old_print

这里发生了什么?

  1. 首先,我们创建模拟函数。

  2. 接下来,我们保存默认的打印实现。我们需要这样做,以便在测试结束时能够正确地恢复它。

  3. 最后,我们用我们的模拟函数替换默认的打印。现在,每次调用打印函数时,它都会调用我们的模拟。

  4. 我们在一个try - finally块中运行测试。

  5. finally块中,我们恢复默认的打印实现。

非常、非常重要的是要恢复默认实现。记住,我们在这里更改的是全局信息,如果我们不恢复它,打印将指向我们的模拟函数,在所有后续的测试中也是如此。这可能会导致一些非常奇怪的行为,例如,在其他地方我们可能期望在屏幕上有输出,但没有打印出来,我们最终花费数小时试图弄清楚原因。这就是为什么测试被包裹在try - finally块中的原因。这样,即使在测试中抛出异常,模拟也会被重置回默认值。

我们刚刚看到了如何使用模拟修补函数和类,由于这是一个相当常见的任务,Python 通过mock.patch函数给了我们一个非常好的进行修补的方法。

mock.patch函数去除了修补函数所需的大量工作。让我们看看使用它的几种方法。

第一种方法复制了我们手动修补的方式。我们创建一个修补器,然后使用start方法来执行修补,使用stop方法来重置回原始实现,如下所示:

    def test_executing_action_prints_message(self):
        patcher = mock.patch('builtins.print')
        mock_print = patcher.start()
        try:
            action = PrintAction()
            action.execute("GOOG > $10")
            mock_print.assert_called_with("GOOG > $10")
        finally:
            patcher.stop()

就像我们的手动修补一样,我们必须小心,即使在抛出异常的情况下也要调用停止方法。

修补也可以与with关键字一起用作上下文管理器。这种语法要干净得多,通常比我们自己调用开始和停止更可取:

    def test_executing_action_prints_message(self):
        with mock.patch('builtins.print') as mock_print:
            action = PrintAction()
            action.execute("GOOG > $10")
            mock_print.assert_called_with("GOOG > $10")

让我们来看看这里发生了什么:

  • 我们希望修补激活的代码被包裹在with块中。

  • 我们调用patch函数,它返回要作为上下文管理器使用的修补器。模拟对象被设置在as部分指定的变量中。在这种情况下,修补后的模拟对象被设置为mock_print

  • 在这个块中,我们像往常一样执行测试和断言。

  • 修补一旦执行离开上下文块就会被移除。这可能是因为所有语句都已执行,或者由于异常。

使用这种语法,我们不需要担心未处理的异常会导致修补出现问题。

patch函数也可以用作方法装饰器,如下面的示例所示:

    @mock.patch("builtins.print")
    def test_executing_action_prints_message(self, mock_print):
        action = PrintAction()
        action.execute("GOOG > $10")
        mock_print.assert_called_with("GOOG > $10")

使用此语法,修补器修补所需的函数,并将替换模拟对象作为测试方法的第一个参数传入。然后我们可以像正常使用一样使用模拟对象。测试完成后,修补将被重置。

如果我们需要为多个测试修补相同的对象,则可以使用类装饰器语法,如下所示:

@mock.patch("builtins.print")
class PrintActionTest(unittest.TestCase):
    def test_executing_action_prints_message(self, mock_print):
        action = PrintAction()
        action.execute("GOOG > $10")
        mock_print.assert_called_with("GOOG > $10")

此语法用修补装饰了类中的所有测试。默认情况下,装饰器会搜索以test开头的方法。但是,可以通过设置patch.TEST_PREFIX属性来更改此行为,类装饰器将修补所有以该前缀开头的方法。

修补时的一个重要注意事项

在修补时,我们应该记住修补类所使用的确切对象。Python 允许多个对象引用,很容易修补错误的对象。然后我们可能会花费数小时 wondering 为什么模拟对象没有被执行。

例如,支持文件alert.py使用如下导入:

from rule import PriceRule

现在在警报测试中,如果我们想要修补PriceRule,那么我们需要这样做:

import alert

@mock.patch("alert.PriceRule")
def test_patch_rule(self, mock_rule):
    ....

只有这样做,我们才能修补在alert.py文件中使用的PriceRule对象。以下方式将不起作用:

import rule

@mock.patch("rule.PriceRule")
def test_patch_rule(self, mock_rule):
    ....

此代码将修补rule.PriceRule,这与我们想要修补的实际对象不同。当我们运行此测试时,我们会看到警报执行的是真实的PriceRule对象,而不是我们修补出的那个。

由于这是一个常见的错误,所以当我们遇到测试无法正确执行修补对象的问题时,我们应该首先检查这一点。

将所有内容串联起来

让我们用一个更复杂的例子来总结这一章。以下是EmailAction类的代码。此操作在规则匹配时向用户发送电子邮件。

import smtplib
from email.mime.text import MIMEText

class EmailAction:
    """Send an email when a rule is matched"""
    from_email = "alerts@stocks.com"

    def __init__(self, to):
        self.to_email = to

    def execute(self, content):
        message = MIMEText(content)
        message["Subject"] = "New Stock Alert"
        message["From"] = "alerts@stocks.com"
        message["To"] = self.to_email
        smtp = smtplib.SMTP("email.stocks.com")
        try:
            smtp.send_message(message)
        finally:
            smtp.quit()

以下是如何使用该库:

  1. 我们在smtplib库中实例化SMTP类,并传入我们想要连接的服务器。这将返回SMTP对象。

  2. 我们在SMTP对象上调用send_message方法,传入电子邮件消息详情,以MIMEText对象的形式。

  3. 最后,我们调用quit方法。此方法始终需要调用,即使在发送消息时发生异常。

基于此,我们需要测试以下内容:

  1. 调用smtplib库时,使用正确的参数。

  2. 消息内容(发件人、收件人、主题、正文)是正确的。

  3. 即使在发送消息时抛出异常,也会调用quit方法。

让我们从简单的测试开始。此测试是为了验证SMTP类是否使用正确的参数初始化:

class EmailActionTest(unittest.TestCase):
    def setUp(self):
        self.action = EmailAction(to="siddharta@silverstripesoftware.com")

    def test_email_is_sent_to_the_right_server(self, mock_smtp_class):
        self.action.execute("MSFT has crossed $10 price level")
        mock_smtp_class.assert_called_with("email.stocks.com")

首先,我们在每个测试中修补smtplib模块中的SMTP类。由于我们将为此做每个测试,我们将此设置为一个类装饰器。然后在setUp中实例化我们想要测试的EmailAction

测试本身相当简单。我们调用动作的execute方法,并断言模拟类是用正确的参数实例化的。

以下测试验证了对SMTP对象是否执行了正确的调用:

    def test_connection_closed_after_sending_mail(self, mock_smtp_class):
        mock_smtp = mock_smtp_class.return_value
        self.action.execute("MSFT has crossed $10 price level")
        mock_smtp.send_message.assert_called_with(mock.ANY)
        self.assertTrue(mock_smtp.quit.called)
        mock_smtp.assert_has_calls([
            mock.call.send_message(mock.ANY),
            mock.call.quit()])

在这个测试中,有几个新的方法值得讨论。

首先是这个测试系列中的一个微妙差异,其中我们模拟的是SMTP类而不是一个对象。在第一个测试中,我们检查了传递给构造函数的参数。由于我们模拟了类,我们可以在我们的模拟对象上直接断言。

在这个测试中,我们需要检查是否在SMTP 对象上执行了正确的调用。由于对象是初始化类的返回值,我们可以从模拟smtp类的返回值中访问模拟的smtp对象。这正是测试的第一行所做的事情。

接下来,我们像往常一样执行动作。

最后,我们使用assert_has_calls方法断言执行了正确的调用。我们可以像以下这样断言调用:

        mock_smtp.send_message.assert_called_with(mock.ANY)
        self.assertTrue(mock_smtp.quit.called)

主要区别在于上述断言没有断言序列。假设动作首先调用quit方法,然后调用send_message,它仍然会通过这两个断言。然而,assert_has_calls断言不仅检查方法是否被调用,还检查quit方法是在send_message之后被调用的。

以下第三个测试检查即使在发送消息时抛出异常,连接也会关闭:

    def test_connection_closed_if_send_gives_error(self, mock_smtp_class):
        mock_smtp = mock_smtp_class.return_value
        mock_smtp.send_message.side_effect =
        smtplib.SMTPServerDisconnected()
        try:
            self.action.execute("MSFT has crossed $10 price level")
        except Exception:
            pass
        self.assertTrue(mock_smtp.quit.called)

在这个测试中,我们使用side_effect属性将发送消息的模拟设置为抛出异常。然后我们检查即使在抛出异常的情况下,quit方法也被调用了。

在最后一个测试中,我们需要检查正确的消息内容是否传递给了send_message。该函数接受一个MIMEText对象作为参数。我们如何检查传递了正确的对象?

以下是一种不工作的方法:

    def test_email_is_sent_with_the_right_subject(self, mock_smtp_class):
        mock_smtp = mock_smtp_class.return_value
        self.action.execute("MSFT has crossed $10 price level")
        message = MIMEText("MSFT has crossed $10 price level")
        message["Subject"] = "New Stock Alert"
        message["From"] = "alerts@stocks.com"
        message["To"] = "siddharta@silverstripesoftware.com"
        mock_smtp.send_message.assert_called_with(message)

如果我们运行上述测试,我们会得到如下失败:

AssertionError: Expected call: send_message(<email.mime.text.MIMEText object at 0x0000000003641F98>)
Actual call: send_message(<email.mime.text.MIMEText object at 0x000000000363A0F0>)

问题在于,尽管预期的MIMEText对象和传递给send_message的实际对象的内容相同,但测试仍然失败,因为它们是两个不同的对象。模拟框架通过相等性比较这两个参数,由于它们都是两个不同的对象,所以相等性测试失败。

解决这个问题的方法之一是进入模拟,提取调用中传递的参数,并检查它们是否包含正确的数据。以下是一个使用此方法的测试:

    def test_email_is_sent_with_the_right_subject(self, mock_smtp_class):
        mock_smtp = mock_smtp_class.return_value
        self.action.execute("MSFT has crossed $10 price level")
        call_args, _ = mock_smtp.send_message.call_args
        sent_message = call_args[0]
        self.assertEqual("New Stock Alert", sent_message["Subject"])

一旦调用execute方法,我们就访问mock对象的call_args属性,以获取传递给send_message的参数。我们取第一个参数,即我们感兴趣的MIMEText对象。然后我们断言主题符合预期。

有一种更优雅的方法。记住我们说过模拟框架是通过相等性来比较参数的吗?这意味着我们可以传递一个实现了__eq__特殊方法的对象,并使用它来执行我们想要的任何比较。以下是一个用于检查两个MIMEText消息之间相等性的此类类:

class MessageMatcher:
    def __init__(self, expected):
        self.expected = expected

    def __eq__(self, other):
        return self.expected["Subject"] == other["Subject"] and \
            self.expected["From"] == other["From"] and \
            self.expected["To"] == other["To"] and \
            self.expected["Message"] == other._payload

这个类基本上接受一个值字典,然后可以用来比较MIMEText对象是否包含这些值(至少是我们感兴趣的值)。由于它实现了__eq__方法,可以直接使用相等性进行检查,如下所示:

>>> message = MIMEText("d")
>>> message["Subject"] = "a"
>>> message["From"] = "b"
>>> message["To"] = "c"
>>> expected = MessageMatcher({"Subject":"a", "From":"b", "To":"c", "Message":"d"})
>>> message == expected
True

我们可以使用这种技术将这样的对象作为测试的预期参数传递,如下所示:

    def test_email_is_sent_when_action_is_executed(self, mock_smtp_class):
        expected_message = {
            "Subject": "New Stock Alert",
            "Message": "MSFT has crossed $10 price level",
            "To": "siddharta@silverstripesoftware.com",
            "From": "alerts@stocks.com"
        }
        mock_smtp = mock_smtp_class.return_value
        self.action.execute("MSFT has crossed $10 price level")
        mock_smtp.send_message.assert_called_with(
            MessageMatcher(expected_message))

编写像这样的自定义参数匹配器是一种简单的方法,可以断言我们可能没有直接对象访问的参数,或者当我们只想为了测试目的比较对象的一些属性时。

摘要

在本章中,你学习了如何使用模拟来测试对象之间的交互。你看到了如何手动编写我们的模拟,然后是使用 Python 标准库中提供的模拟框架。接下来,你看到了如何使用补丁进行更高级的模拟。我们通过查看一个稍微复杂一些的模拟示例来结束,这个示例让我们将所有模拟技术付诸实践。

到目前为止,你一直在查看编写新代码的测试。在下一章中,你将了解如何处理没有测试的现有代码。

第五章:工作于遗留代码

拥有一套稳固的单元测试对于成功的项目至关重要。如您所见,单元测试不仅有助于防止错误进入代码,而且在许多其他方面也有帮助,如指导设计、使我们能够重构代码并保持其可维护性,以及作为参考,让您可以看到预期的行为应该是怎样的。

TDD(测试驱动开发)是确保我们的代码具有前一段所述所有特性的最佳方式。但是,任何参与过更大、更复杂项目的人都知道,总有一些代码片段没有经过测试。通常,这些是多年前编写的代码,在我们开始实践 TDD 之前就已经存在了。或者,它可能是为了赶在紧急截止日期前匆忙编写的代码。

无论哪种方式,这都是没有关联测试的代码。代码通常很混乱。它对其他类有大量的依赖。现在,我们需要向这段代码添加一个新功能。我们该如何着手?我们只是直接进去修改新功能吗?还是有一种更好的方法?

什么是遗留代码?

在本章中,我们将使用术语遗留代码来指代任何没有单元测试的代码。这是一个相当宽泛的定义,因为它包括很久以前编写的代码以及某些原因下没有编写测试的最近代码。虽然这并不是严格意义上的旧代码,但在 TDD 社区中,这是一个流行的定义,由迈克尔·费瑟斯的优秀著作《与遗留代码有效工作》(Prentice Hall,2004)使之流行起来,我们也将在这个书中采用这个含义。

与遗留代码工作的五个步骤:

  1. 理解代码:如果我们很幸运,我们会有一些优秀的文档来帮助我们理解即将接触的代码。更有可能的是,文档会很少,或者根本不存在。由于没有测试,我们无法阅读测试来尝试理解代码应该做什么。而对于非常旧的代码,编写代码的人可能已经不再为您的组织工作了。这听起来就像是一场完美的风暴,会给我们带来麻烦,但正如任何参与过大型生产项目的人都可以证明的那样,这是大多数代码库的常态。因此,我们的第一步就是理解代码,弄清楚发生了什么。

  2. 打破依赖关系:一旦我们开始理解代码,我们的下一步就是为代码编写一些测试。对于遗留代码来说,这并不简单,因为设计通常是与其他文件和类相互依赖的意大利面式的混乱。在我们编写单元测试之前,我们需要某种方法来打破这些依赖关系。

  3. 编写测试:我们现在终于可以为我们即将修改的代码编写一些单元测试了。

  4. 重构:现在我们已经有了测试,我们可以开始应用我们在本书前面看到的一些重构技术。

  5. 实现新功能:在清理代码后,我们现在可以实施新功能,当然,包括测试。

虽然前面的步骤被显示为线性序列,但重要的是要理解,步骤往往以非线性的方式进行。例如,当我们试图理解一个大方法时,我们可能会取一小段代码,将其提取为方法,更详细地查看它,然后为它编写几个测试,最后回到原始方法并查看方法的另一部分。然后我们可能回到我们提取的方法并将它们提取到一个新类中。步骤来回进行,直到我们处于一个可以安全实施新功能且没有破坏东西的风险的位置。

理解代码

以下是我们将在本章中查看的代码:

from datetime import datetime

from .stock import Stock
from .rule import PriceRule

class AlertProcessor:
    def __init__(self):
        self.exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}
        rule_1 = PriceRule("GOOG", lambda stock: stock.price > 10)
        rule_2 = PriceRule("AAPL", lambda stock: stock.price > 5)
        self.exchange["GOOG"].updated.connect(
            lambda stock: print(stock.symbol, stock.price) \
                if rule_1.matches(self.exchange) else None)
        self.exchange["AAPL"].updated.connect(
            lambda stock: print(stock.symbol, stock.price) \
                if rule_2.matches(self.exchange) else None)

        updates = []
        with open("updates.csv", "r") as fp:
            for line in fp.readlines():
                symbol, timestamp, price = line.split(",")
                updates.append((symbol, datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f"), int(price)))

        for symbol, timestamp, price in updates:
            stock = self.exchange[symbol]
            stock.update(timestamp, price)

这是一段执行某些操作的代码。我们所知道的是,它从文件中获取一些更新并通过一些警报运行它。以下是如何看起来updates.csv文件:

GOOG,2014-02-11T14:10:22.13,5
AAPL,2014-02-11T00:00:00.0,8
GOOG,2014-02-11T14:11:22.13,3
GOOG,2014-02-11T14:12:22.13,15
AAPL,2014-02-11T00:00:00.0,10
GOOG,2014-02-11T14:15:22.13,21

我们现在需要向这段代码添加一些功能:

  • 我们需要能够从网络服务器获取更新

  • 我们需要能够在匹配到警报时发送电子邮件

在我们开始之前,我们需要能够理解当前的代码。我们通过特征测试来实现这一点。

什么是特征测试?

特征测试是描述代码当前行为的测试。我们不是针对预定义的期望来编写测试,而是针对实际行为来编写测试。你可能会问这能完成什么,因为如果我们打算查看当前行为并编写一个寻找相同内容的测试,测试是不可能失败的。然而,需要记住的是,我们并不是试图找到错误。相反,通过针对当前行为编写测试,我们正在构建一个测试的安全网。如果我们重构过程中破坏了某些东西,测试将失败,我们将知道我们必须撤销我们的更改。

使用 Python 交互式外壳来理解代码

那么,这段代码做什么呢?让我们打开 Python 交互式外壳并看看。交互式外壳是一个很好的帮助工具,因为它允许我们与代码互动,尝试不同的输入值并查看我们得到什么样的输出。现在让我们按照以下方式打开类:

>>> from stock_alerter.legacy import AlertProcessor
>>> processor = AlertProcessor()
AAPL 8
GOOG 15
AAPL 10
GOOG 21
>>>

如我们所见,仅仅实例化类就使代码运行,我们在终端上打印了一些输出。考虑到所有代码都在__init__方法中,这并不令人惊讶。

编写特征测试

好的,我们现在有了一些可以编写测试的内容。我们理解,当updates.csv文件中的输入如下时:

GOOG,2014-02-11T14:10:22.13,5
AAPL,2014-02-11T00:00:00.0,8
GOOG,2014-02-11T14:11:22.13,3
GOOG,2014-02-11T14:12:22.13,15
AAPL,2014-02-11T00:00:00.0,10
GOOG,2014-02-11T14:15:22.13,21

然后,当我们实例化类时的输出如下:

AAPL 8
GOOG 15
AAPL 10
GOOG 21

我们可能还不知道为什么这是输出或它是如何计算的,但这足以开始测试。以下就是测试的样子:

import unittest
from unittest import mock

from ..legacy import AlertProcessor

class AlertProcessorTest(unittest.TestCase):
    @mock.patch("builtins.print")
    def test_processor_characterization_1(self, mock_print):
        AlertProcessor()
        mock_print.assert_has_calls([mock.call("AAPL", 8),
                                     mock.call("GOOG", 15),
                                     mock.call("AAPL", 10),
                                     mock.call("GOOG", 21)])

所有这些测试所做的只是模拟print函数并实例化类。我们断言所需的数据被打印出来。

这是一个伟大的单元测试吗?可能不是。首先,它仍然从updates.csv文件中获取输入。理想情况下,我们会模拟文件访问。但此刻这并不重要。这个测试通过了,并且当开始修改代码时,它将是一个安全网。这就是我们现在需要的测试所做的一切。

使用 pdb 理解代码

Python 交互式外壳是理解方法调用边界代码的好方法。它允许我们传入各种输入组合并查看我们得到什么样的输出。但如果我们想看到函数或方法内部发生的事情呢?这就是pdb能极其有用的时候。

pdb是 Python 调试器,它是 Python 标准库的一部分。它有许多功能,例如能够逐行执行代码,查看变量如何变化,以及设置和删除断点。pdb 非常强大,有许多好书详细介绍了它。我们不会在这本书中介绍所有功能,但只是给出一个简短的例子,说明如何使用它来理解代码。

要在pdb中执行代码,请在交互式外壳中运行以下行:

>>> import pdb
>>> from stock_alerter.legacy import AlertProcessor
>>> pdb.run("AlertProcessor()")
> <string>(1)<module>()
(Pdb)

pdb.run方法允许我们指定任何字符串作为参数。这个字符串将在调试器中执行。在这种情况下,我们正在实例化开始执行所有代码的类。

在这一点上,我们得到了(Pdb)提示符,从这里我们可以逐行执行代码。你可以通过输入help来获取各种命令的帮助,如下所示:

(Pdb) help

Documented commands (type help <topic>):
EOF    c          d        h         list      q        rv       undisplay
a      cl         debug    help      ll        quit     s        unt
alias  clear      disable  ignore    longlist  r        source   until
args   commands   display  interact  n         restart  step     up
b      condition  down     j         next      return   tbreak   w
break  cont       enable   jump      p         retval   u        whatis
bt     continue   exit     l         pp        run      unalias  where

Miscellaneous help topics:
pdb  exec

(Pdb)

或者,你也可以通过输入help <command>来获取特定命令的帮助:

(Pdb) help s
s(tep)
        Execute the current line, stop at the first possible occasion
        (either in a function that is called or in the current
        function).
(Pdb)

一些常见的 pdb 命令

大多数时候,我们会使用以下命令:

  • s: 这将执行一行代码(如果需要,将进入函数调用)

  • n: 这将执行代码直到你达到当前函数的下一行

  • r: 这将执行代码直到当前函数返回

  • q: 这将退出调试器

  • b: 这将在文件的特定行上设置断点

  • cl: 这将清除断点

  • c: 这将继续执行直到遇到断点或执行结束

这些命令应该足以在代码中移动并尝试检查正在发生的事情。pdb 还有许多其他命令,我们在这里不会介绍。

漫步 pdb 会话

现在我们来实际操作。以下是我们使用pdb遍历代码的过程。

首先,我们在pdb中运行我们的命令,如下所示:

>>> import pdb
>>> from stock_alerter.legacy import AlertProcessor
>>> pdb.run("AlertProcessor()")
> <string>(1)<module>()
(Pdb)

让我们按照以下方式进入第一行:

(Pdb) s
--Call—
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(8)__init__()
-> def __init__(self):
(Pdb)

pdb 告诉我们我们现在在__init__方法中。n命令将带我们通过这个方法的前几行,如下所示:

(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(9)__init__()
-> self.exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}
(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(10)__init__()
-> rule_1 = PriceRule("GOOG", lambda stock: stock.price > 10)
(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(11)__init__()
-> rule_2 = PriceRule("AAPL", lambda stock: stock.price > 5)
(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(12)__init__()
-> self.exchange["GOOG"].updated.connect(
(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(13)__init__()
-> lambda stock: print(stock.symbol, stock.price) \
(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(15)__init__()
-> self.exchange["AAPL"].updated.connect(
(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(16)__init__()
-> lambda stock: print(stock.symbol, stock.price) \
(Pdb) n
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(18)__init__()
-> updates = []
(Pdb)

我们可以通过查看一些变量来检查似乎已经完成的初始化,如下所示:

(Pdb) self.exchange
{'GOOG': <stock_alerter.stock.Stock object at 0x0000000002E59400>, 'AAPL': <stock_alerter.stock.Stock object at 0x0000000002E593C8>}
(Pdb) rule_1
<stock_alerter.rule.PriceRule object at 0x0000000002E205F8>

我们甚至可以尝试用各种输入执行一些局部变量,如下所示:

(Pdb) test_stock = Stock("GOOG")
(Pdb) test_stock.update(datetime.now(), 100)
(Pdb) rule_1.matches({"GOOG": test_stock})
True
(Pdb)

这有助于我们了解执行不同部分时各种对象的状态。

下一节代码是打开文件并读取的部分。让我们通过在 25 行设置断点并使用c命令直接执行来跳过这部分,如下所示:

(Pdb) b stock_alerter\legacy.py:25
Breakpoint 1 at c:\projects\tdd_with_python\src\stock_alerter\legacy.py:25
(Pdb) c
> c:\projects\tdd_with_python\src\stock_alerter\legacy.py(25)__init__()
-> for symbol, timestamp, price in updates:
(Pdb)

现在文件读取部分已经完成,我们可以通过检查更新的局部变量来检查读取的数据格式。pp命令进行美化打印,以便输出更容易阅读,如下所示:

(Pdb) pp updates
[('GOOG', datetime.datetime(2014, 2, 11, 14, 10, 22, 130000), 5),
 ('AAPL', datetime.datetime(2014, 2, 11, 0, 0), 8),
 ('GOOG', datetime.datetime(2014, 2, 11, 14, 11, 22, 130000), 3),
 ('GOOG', datetime.datetime(2014, 2, 11, 14, 12, 22, 130000), 15),
 ('AAPL', datetime.datetime(2014, 2, 11, 0, 0), 10),
 ('GOOG', datetime.datetime(2014, 2, 11, 14, 15, 22, 130000), 21)]

看起来文件被解析为包含(股票代码,时间戳,价格)的元组列表。让我们看看如果我们只有 GOOG 更新会发生什么,如下所示:

(Pdb) updates = [update for update in updates if update[0] == "GOOG"]
(Pdb) pp updates
[('GOOG', datetime.datetime(2014, 2, 11, 14, 10, 22, 130000), 5),
 ('GOOG', datetime.datetime(2014, 2, 11, 14, 11, 22, 130000), 3),
 ('GOOG', datetime.datetime(2014, 2, 11, 14, 12, 22, 130000), 15),
 ('GOOG', datetime.datetime(2014, 2, 11, 14, 15, 22, 130000), 21)]

我们继续。正如我们所见,甚至在执行过程中中途改变局部变量的值也是可能的。以下是我们运行代码剩余部分时的输出:

(Pdb) cl
Clear all breaks? y
Deleted breakpoint 4 at c:\projects\tdd_with_python\src\stock_alerter\legacy.py:25
(Pdb) c
GOOG 15
GOOG 21
>>>

cl命令清除断点,我们使用c命令运行到执行的末尾。修改后的更新变量输出被打印出来。由于此时执行已经完成,我们被返回到交互式 shell。

我们现在的探索已经完成。在任何时候,我们都可以像下面这样退出调试器:

(Pdb) q
>>>

退出调试器将我们带回到交互式 shell。在这个时候,我们可能会根据我们刚刚进行的探索添加一些更多的特征测试。

打破依赖关系的技巧

现在我们已经看到了一些帮助我们理解代码的技巧,我们的下一步是打破依赖关系。这将帮助我们编写进一步的特性测试。为此,我们将非常小心地开始修改代码。在此期间,我们将尽量坚持以下目标:

  • 进行非常不可能破坏的小改动

  • 尽量少改变公共接口

为什么有这些目标?因为我们缺乏测试,我们必须对所做的更改保持谨慎。因此,小改动更好。我们还需要注意不要改变公共接口,因为我们必须去修复所有使用这个类的其他文件和模块。

线索重构库

线索重构库是一个用于执行代码自动重构的库。例如,你可以选择几行代码,然后输入命令将其提取为方法。库将自动创建这个方法,包括适当的代码、参数和返回值,并将自动在原始代码的位置放置对新提取方法的调用。在 Python 中自动重构有点棘手,因为语言的动态特性使得正确识别所有更改变得困难。然而,它非常适合进行小改动,就像我们将在本章中做的那样。

由于它是一个库,Rope 没有用于执行重构的 UI。相反,它集成到开发环境中,作为 IDE 或文本编辑器。大多数流行的 IDE 和文本编辑器都支持与 Rope 集成。Rope 可在github.com/python-rope/rope找到。

如果你的 IDE 或你选择的文本编辑器支持与 Rope 集成,或者有内置的重构功能,那么尽可能使用它。

将初始化与执行分离

我们正在工作的班级遇到的一个问题是整个执行过程都发生在__init__方法中。这意味着一旦类被构造,所有操作都会在我们有机会设置模拟或进行其他有助于编写特征测试的更改之前执行。幸运的是,这个问题有一个简单的解决方案。我们将简单地将执行部分移动到一个单独的方法中,如下所示:

class AlertProcessor:
    def __init__(self):
        self.exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}
        rule_1 = PriceRule("GOOG", lambda stock: stock.price > 10)
        rule_2 = PriceRule("AAPL", lambda stock: stock.price > 5)
        self.exchange["GOOG"].updated.connect(
            lambda stock: print(stock.symbol, stock.price) \
                          if rule_1.matches(self.exchange) else None)
        self.exchange["AAPL"].updated.connect(
            lambda stock: print(stock.symbol, stock.price) \
                          if rule_2.matches(self.exchange) else None)

    def run(self):
        updates = []
        with open("updates.csv", "r") as fp:
            for line in fp.readlines():
                symbol, timestamp, price = line.split(",")
                updates.append(
                       (symbol,
                        datetime.strptime(timestamp,
                                          "%Y-%m-%dT%H:%M:%S.%f"),
                        int(price)))

        for symbol, timestamp, price in updates:
            stock = self.exchange[symbol]
            stock.update(timestamp, price)

聪明的读者可能会注意到,我们刚刚打破了我们的第二个目标——最小化对公共接口的更改。我们所做的更改已经改变了接口。如果有其他模块使用这个类,它们只会构造这个类,假设所有处理都已经完成。我们现在必须找到所有创建这个类的位置,并添加对run方法的调用。否则,这个类将无法按预期工作。

为了避免需要修复所有调用者,我们可以在初始化器内部自己调用run方法,如下所示:

def __init__(self):
    self.exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}
    rule_1 = PriceRule("GOOG", lambda stock: stock.price > 10)
    rule_2 = PriceRule("AAPL", lambda stock: stock.price > 5)
    self.exchange["GOOG"].updated.connect(
        lambda stock: print(stock.symbol, stock.price) \
                      if rule_1.matches(self.exchange) else None)
    self.exchange["AAPL"].updated.connect(
        lambda stock: print(stock.symbol, stock.price) \
                      if rule_2.matches(self.exchange) else None)
    self.run()

所有测试都通过了,但又一次,所有代码在实例化类的那一刻就会执行。我们是不是回到了起点?让我们在下一节中看看。

为参数使用默认值

Python 最有用的特性之一是能够为参数设置默认值的概念。这允许我们更改接口,同时让现有的调用者看起来没有变化。

在上一节中,我们将一段代码移动到了run方法中,并从__init__方法中调用了这个方法。这似乎我们没有真正改变什么,但这是一种误导。

下面是__init__方法的下一个更改:

def __init__(self, autorun=True):
    self.exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}
    rule_1 = PriceRule("GOOG", lambda stock: stock.price > 10)
    rule_2 = PriceRule("AAPL", lambda stock: stock.price > 5)
    self.exchange["GOOG"].updated.connect(
        lambda stock: print(stock.symbol, stock.price) \
                      if rule_1.matches(self.exchange) else None)
    self.exchange["AAPL"].updated.connect(
        lambda stock: print(stock.symbol, stock.price) \
                      if rule_2.matches(self.exchange) else None)
    if autorun:
        self.run()

我们所做的是引入了一个名为autorun的新参数,并将其默认值设置为True。然后我们用条件语句包装对run方法的调用。只有当autorunTrue时,才会调用run方法。

所有现有的调用者使用这个类将保持不变——当不带参数调用构造函数时,autorun参数将被设置为True,并将调用run方法。一切都将如预期。

但添加参数给了我们一个选项,可以在测试中显式地将autorun参数设置为False,从而避免调用run方法。我们现在可以实例化这个类,然后设置我们想要的任何模拟或其他测试初始化,然后手动在测试中调用run方法。

下面的特征测试与之前我们编写的相同,但重写以利用这个新功能:

def test_processor_characterization_2(self):
    processor = AlertProcessor(autorun=False)
    with mock.patch("builtins.print") as mock_print:
        processor.run()
    mock_print.assert_has_calls([mock.call("AAPL", 8),
                                 mock.call("GOOG", 15),
                                 mock.call("AAPL", 10),
                                 mock.call("GOOG", 21)])

哈哈!现在这个变化看起来很小,但它正是使我们能够编写所有后续特征测试的变化。

提取方法并测试

测试大的方法非常困难。这是因为测试只能检查输入、输出和交互。如果整个方法中只有几行是我们想要测试的,那么这就会成为一个问题。

让我们再次看看run方法,如下所示:

def run(self):
    updates = []
    with open("updates.csv", "r") as fp:
        for line in fp.readlines():
            symbol, timestamp, price = line.split(",")
            updates.append((symbol, datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f"), int(price)))

    for symbol, timestamp, price in updates:
        stock = self.exchange[symbol]
        stock.update(timestamp, price)

假设我们只想为第二循环中的代码编写特征测试。如何做到这一点?一个简单的方法是将这些行提取到一个单独的方法中,如下所示:

def do_updates(self, updates):
    for symbol, timestamp, price in updates:
        stock = self.exchange[symbol]
        stock.update(timestamp, price)

我们需要在原始位置调用这个新方法,如下所示:

def run(self):
    updates = []
    with open("updates.csv", "r") as fp:
        for line in fp.readlines():
            symbol, timestamp, price = line.split(",")
            updates.append((symbol, datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f"), int(price)))
    self.do_updates(updates)

我们现在可以按照以下方式为这个新方法编写特征测试:

def test_processor_characterization_3(self):
    processor = AlertProcessor(autorun=False)
    mock_goog = mock.Mock()
    processor.exchange = {"GOOG": mock_goog}
    updates = [("GOOG", datetime(2014, 12, 8), 5)]
    processor.do_updates(updates)
    mock_goog.update.assert_called_with(datetime(2014, 12, 8), 5)

理想情况下,我们尝试提取小的代码组,以便在不犯错误的情况下轻松执行提取方法重构。记住,我们在这里没有现有单元测试的安全网。

注入依赖

在先前的特征测试中,我们实例化了类,然后继续用另一个实例变量替换交换实例变量,其中Stock类被模拟。实现这一目标的另一种方法是使用以下早期技巧引入默认变量:

def __init__(self, autorun=True, exchange=None):
    if exchange is None:
        self.exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}
    else: 
        self.exchange = exchange
    rule_1 = PriceRule("GOOG", lambda stock: stock.price > 10)
    rule_2 = PriceRule("AAPL", lambda stock: stock.price > 5)
    self.exchange["GOOG"].updated.connect(
        lambda stock: print(stock.symbol, stock.price) \
            if rule_1.matches(self.exchange) else None)
    self.exchange["AAPL"].updated.connect(
        lambda stock: print(stock.symbol, stock.price) \
            if rule_2.matches(self.exchange) else None)
    if autorun:
        self.run()

这允许我们在编写特征测试时注入模拟,如下所示:

def test_processor_characterization_4(self):
    mock_goog = mock.Mock()
    mock_aapl = mock.Mock()
    exchange = {"GOOG": mock_goog, "AAPL": mock_aapl}
    processor = AlertProcessor(autorun=False, exchange=exchange)
    updates = [("GOOG", datetime(2014, 12, 8), 5)]
    processor.do_updates(updates)
    mock_goog.update.assert_called_with(datetime(2014, 12, 8), 5)

继承和测试

实现这一目标的另一种方法是编写一个从AlertProcessor继承的类,但包含依赖参数。

例如,我们可以在测试文件中创建一个如下所示的类:

class TestAlertProcessor(AlertProcessor):
    def __init__(self, exchange):
        AlertProcessor.__init__(self, autorun=False)
        self.exchange = exchange

这个类从AlertProcessor继承,并接受我们在特征测试中想要模拟的参数。__init__方法调用原始类的初始化器,然后覆盖exchange参数为其传递的值。

在单元测试中,我们可以实例化测试类而不是真实类。我们传递一个包含模拟股票对象的交换。模拟被设置,我们可以测试是否调用了正确的调用,如下所示:

def test_processor_characterization_5(self):
    mock_goog = mock.Mock()
    mock_aapl = mock.Mock()
    exchange = {"GOOG": mock_goog, "AAPL": mock_aapl}
    processor = TestAlertProcessor(exchange)
    updates = [("GOOG", datetime(2014, 12, 8), 5)]
    processor.do_updates(updates)
    mock_goog.update.assert_called_with(datetime(2014, 12, 8), 5)

与通过默认参数注入依赖相比,这种方法的优势在于它不需要在原始类中更改任何代码。

模拟局部方法

大多数时候,我们使用模拟来代替被测试类之外的其它类或函数。例如,我们在上一节的例子中模拟了Stock对象,并在本章早期模拟了内置的print函数。

然而,Python 并不允许我们模拟测试中相同类的成员方法。这是一种测试复杂类的强大方式。

假设我们想要测试run方法中解析文件的代码,而不执行更新股票价值的部分。以下是一个仅执行此操作的测试:

def test_processor_characterization_6(self):
    processor = AlertProcessor(autorun=False)
    processor.do_updates = mock.Mock()
    processor.run()
    processor.do_updates.assert_called_with([
        ('GOOG', datetime(2014, 2, 11, 14, 10, 22, 130000), 5),
        ('AAPL', datetime(2014, 2, 11, 0, 0), 8),
        ('GOOG', datetime(2014, 2, 11, 14, 11, 22, 130000), 3),
        ('GOOG', datetime(2014, 2, 11, 14, 12, 22, 130000), 15),
        ('AAPL', datetime(2014, 2, 11, 0, 0), 10),
        ('GOOG', datetime(2014, 2, 11, 14, 15, 22, 130000), 21)])

在上面的测试中,我们在执行run方法之前模拟了类的do_updates方法。当我们执行run时,它解析文件,然后不是运行do_updates局部方法,而是执行我们的模拟方法。由于实际方法被模拟,代码不会更新Stock或打印任何内容到屏幕上。所有这些功能都已模拟。然后我们通过检查是否将正确的参数传递给do_updates方法来测试解析是否正确。

模拟局部方法是一个理解更复杂类的好方法,因为它允许我们单独为类的小部分编写特征测试。

提取方法和存根

有时,一个方法可能相当长,我们希望模拟方法的一部分。我们可以通过将我们想要模拟的部分提取到一个局部方法中,然后在测试中模拟该方法来组合上述技术。

以下是我们当前的run方法的样子:

def run(self):
    updates = []
    with open("updates.csv", "r") as fp:
        for line in fp.readlines():
            symbol, timestamp, price = line.split(",")
            updates.append((symbol, datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f"), int(price)))
    self.do_updates(updates)

假设我们希望测试跳过方法中读取和解析文件的部分。

我们首先将行提取为一个方法,如下所示:

def parse_file(self):
    updates = []
    with open("updates.csv", "r") as fp:
        for line in fp.readlines():
            symbol, timestamp, price = line.split(",")
            updates.append((symbol, datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f"), int(price)))
    return updates

然后,我们将run方法中的行替换为新提取的方法调用,如下所示:

def run(self):
    updates = self.parse_file()
    self.do_updates(updates)

最后,特征测试模拟了新提取的方法,给它一个返回值,并调用run方法,如下所示:

def test_processor_characterization_7(self):
    processor = AlertProcessor(autorun=False)
    processor.parse_file = mock.Mock()
    processor.parse_file.return_value = [
        ('GOOG', datetime(2014, 2, 11, 14, 12, 22, 130000), 15)]
    with mock.patch("builtins.print") as mock_print:
        processor.run()
    mock_print.assert_called_with("GOOG", 15)

将文件解析行提取到单独的方法后,我们可以轻松地为不同的输入组合编写多个不同的特征测试。例如,以下是一个检查对于不同输入没有打印到屏幕上的另一个特征测试:

def test_processor_characterization_8(self):
    processor = AlertProcessor(autorun=False)
    processor.parse_file = mock.Mock()
    processor.parse_file.return_value = [
        ('GOOG', datetime(2014, 2, 11, 14, 10, 22, 130000), 5)]
    with mock.patch("builtins.print") as mock_print:
        processor.run()
    self.assertFalse(mock_print.called)

我们还可以使用这项技术来测试难以访问的代码,例如lambda函数。我们将lambda函数提取到一个单独的函数中,这使得我们可以单独为它编写特征测试,或者在编写其他部分代码的测试时模拟它。

让我们对我们的代码做以下操作。首先,将lambda函数提取到一个局部方法中。

def print_action(self, stock, rule):
    print(stock.symbol, stock.price) \
        if rule.matches(self.exchange) else None

然后,将print行替换为对方法的调用,如下所示:

self.exchange["GOOG"].updated.connect(
    lambda stock: self.print_action(stock, rule_1))
self.exchange["AAPL"].updated.connect(
    lambda stock: self.print_action(stock, rule_2))

注意

注意我们的调用方式。我们仍然使用lambda函数,但使用适当的参数委托到局部方法。

现在,我们可以在为do_updates方法编写特征测试时模拟此方法:

def test_processor_characterization_9(self):
    processor = AlertProcessor(autorun=False)
    processor.print_action = mock.Mock()
    processor.do_updates([
        ('GOOG', datetime(2014, 2, 11, 14, 12, 22, 130000), 15)])
    self.assertTrue(processor.print_action.called)

循环继续

上节中提到的所有技术都有助于我们隔离代码片段,并与其他类断开依赖关系。这使我们能够引入存根和模拟,从而更容易编写更详细的特征测试。提取方法重构被大量使用,是一种隔离代码小部分的好技术。

整个过程是迭代的。在典型会话中,我们可能会通过pdb查看一段代码,然后决定将其提取为方法。我们可能会在交互式外壳中尝试向提取的方法传递不同的输入,之后我们可能会编写一些特征测试。然后我们会回到类的另一部分,在模拟或存根新方法后编写更多测试。之后我们可能会回到pdb或交互式外壳,查看另一段代码。

在整个过程中,我们不断进行小的更改,这些更改不太可能破坏现有系统,并持续运行所有现有的特征测试,以确保我们没有破坏任何东西。

重构时间

过了一段时间,我们可能会为遗留代码获得一套相当不错的特征测试。现在我们可以像对待任何经过良好测试的代码一样处理这段代码,并开始应用更大的重构,目的是在添加新功能之前改进设计。

例如,我们可能会决定将print_action方法提取到一个单独的Action类中,或者将parse_file方法提取到一个Reader类中。

以下是一个FileReader类,我们将内容从parse_file局部方法移动到这里:

class FileReader:
    def __init__(self, filename):
        self.filename = filename

    def get_updates(self):
        updates = []
        with open("updates.csv", "r") as fp:
            for line in fp.readlines():
                symbol, timestamp, price = line.split(",")
                updates.append((symbol, datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S.%f"), int(price)))
        return updates

然后我们使用注入依赖模式将reader作为参数传递给构造函数:

def __init__(self, autorun=True, reader=None, exchange=None):
    self.reader = reader if reader else FileReader("updates.csv")
    if exchange is None:
        self.exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}
    else:
        self.exchange = exchange
    rule_1 = PriceRule("GOOG", lambda stock: stock.price > 10)
    rule_2 = PriceRule("AAPL", lambda stock: stock.price > 5)
    self.exchange["GOOG"].updated.connect(
        lambda stock: self.print_action(stock, rule_1))
    self.exchange["AAPL"].updated.connect(
        lambda stock: self.print_action(stock, rule_2))
    if autorun:
        self.run()

然后将run方法更改为调用读取器:

def run(self):
    updates = self.reader.get_updates()
    self.do_updates(updates)

注意我们是如何设置默认值的,这样使用这个类的其他类就不需要更改。这允许我们在测试以及新代码中覆盖reader参数,而现有代码在无需更改的情况下也能正常工作。

我们现在可以通过向构造函数传递一个模拟对象来编写这个测试:

def test_processor_gets_values_from_reader(self):
    mock_reader = mock.MagicMock()
    mock_reader.get_updates.return_value = \
        [('GOOG', datetime(2014, 2, 11, 14, 12, 22, 130000), 15)]
    processor = AlertProcessor(autorun=False, reader=mock_reader)
    processor.print_action = mock.Mock()
    processor.run()
    self.assertTrue(processor.print_action.called)

我们可以通过将print_action方法提取到Action类中并将其作为参数传递来实现同样的操作。

记得我们的原始目标吗?

在本章一开始,我们就说过我们想要实现以下两个功能:

  • 我们需要能够从网络服务器获取更新

  • 当警报匹配时,我们需要能够发送电子邮件

原始设计没有使添加此功能变得容易,我们可能需要对该代码进行一些修改——这是一个危险且容易出错的方案。

我们新重构的设计现在使得添加这些功能变得容易。我们只需要创建新的类,比如创建一个名为NetworkReader的类,它从服务器读取输入。我们通过读取器参数将这个对象的实例传递给初始化器。AlertProcessor将随后从服务器获取更新。

我们可以通过实现一个EmailAction类并将该对象传递给这个类来完成同样的操作。

长期重构

我们已经成功地将新功能安全地添加到我们的遗留代码中。但我们的工作还没有结束。我们在__init__方法中添加了一些默认参数,以便不破坏使用此类的现有代码。随着时间的推移,我们希望逐一访问这些地方,并将它们更改为使用新接口。一旦我们更改了所有地方,我们就可以从接口中删除默认参数,整个代码库就会迁移到新接口。

关于这一点很酷的是,我们不必一次性完成所有更改。代码库永远不会长时间处于破损状态。我们可以逐步进行这些更改,一次一个,应用程序在每一个点上始终是正确运行的。

我们还需要做的一件事是回到我们的特征测试,并对它们进行清理。还记得我们写的第一个特征测试吗?它如下所示:

import unittest
from unittest import mock

from ..legacy import AlertProcessor

class AlertProcessorTest(unittest.TestCase):
    @mock.patch("builtins.print")
    def test_processor_characterization_1(self, mock_print):
        AlertProcessor()
        mock_print.assert_has_calls([mock.call("AAPL", 8),
                                     mock.call("GOOG", 15),
                                     mock.call("AAPL", 10),
                                     mock.call("GOOG", 21)])

在本章的开头,我们提到这并不是一个很好的单元测试,但作为一个特征测试来说已经足够好了。现在,是时候重新审视这个测试,并使其变得更好。经过重构设计后,我们现在可以向读者传递一个模拟对象。测试将不再依赖于updates.csv文件的存在。

我们还进行了一些测试,其中我们修补了打印函数。一旦我们将设计重构为接受Action类作为输入,我们就不再需要修补这个函数,因为我们可以直接传递一个模拟动作对象到初始化器。

摘要

在本章中,你看到了如何处理与遗留代码一起工作的棘手问题。我们将遗留代码定义为任何不包含测试的代码。我们必须处理这种代码是不幸的事实。幸运的是,有一些技术可以让我们安全地处理这种代码。交互式外壳以及极其强大的调试器在理解典型的乱麻代码方面提供了巨大的帮助。

Python 的动态特性也使得打破依赖变得容易。我们可以在重构到更好的设计的同时,使用默认值参数来保持与现有代码的兼容性。强大的修补功能和动态更改现有实例变量和局部方法的能力,使我们能够编写出通常会更困难的特征测试。

现在你已经看到了许多编写测试的方法,让我们来看看如何保持一切可维护。我们将在下一章中这样做。

第六章:维护您的测试套件

如果我们定期进行 TDD,我们可能会很容易地拥有一个包含数千个测试的大测试套件。这很好——它给了我们很大的信心,大胆地添加新功能而不用担心破坏旧功能。然而,我们使测试维护变得容易是至关重要的,否则我们很快就会陷入仅仅管理测试的混乱之中。

没有编写为维护的测试很快就会带来许多头疼问题。散布在文件系统中的测试将使得定位特定测试变得不可能。难以阅读的测试在测试需要因功能更改而更改时,理解和修复将变得困难。长而编写不佳的测试将带来与低质量生产代码相同的问题。脆弱的测试将确保即使是微小的更改也会破坏大量测试。

记住,测试代码仍然是代码。就像任何生产代码一样,我们必须尽力保持其可读性、可维护性和易于更改。我们很容易陷入编写测试用例后忘记它们的陷阱。一年后,我们发现维护测试是一个巨大的头疼问题,添加新功能比以前更困难。

测试维护的目标

正如我们在整本书中看到的那样,单元测试服务于多种不同的目的:

  • 作为测试套件:这是单元测试最明显的目标。一个全面的测试套件可以减少可能逃逸到生产环境的错误数量。

  • 作为文档:当我们试图了解一个类或方法试图做什么时,查看测试套件是有用的。一个编写良好的测试套件将说明代码片段应该如何表现。

  • 作为安全网:这使我们重构或清理代码时的压力得以释放。

  • 展示设计:使用模拟,我们可以描绘不同类或模块之间的交互。

一套编写良好的单元测试的目标是尽可能实现以下目的。

例如,如果我们想了解一个方法的作用,那么我们需要考虑以下问题:

  • 我们能否轻松地找到它的单元测试套件?

  • 一旦找到,理解测试及其测试内容是否容易?

  • 我们能否理解这个方法如何与其他类交互?

  • 一旦我们了解了方法的作用,那么重构它是否容易?进行小的重构是否会破坏所有测试?

  • 是否容易识别由于我们的重构而失败的测试,以及哪些失败是重构中的错误?

  • 如果因为重构需要修改测试用例,那么理解测试并做出所需更改有多困难?

任何大型、长期项目都将涉及回答所有这些问题。

我们在本章中的目标是探讨使执行上述活动更容易的方法。

组织测试

简化测试维护的第一步是有一个系统化的测试组织方式。当我们实现一个新功能时,我们需要能够快速轻松地找到给定方法或类的现有测试代码。为此,有三个步骤。我们必须决定:

  • 文件在文件系统中的存储位置

  • 文件被称为什么

  • 测试类的名称

文件系统布局

决定将我们的测试代码放在哪里的主要考虑因素是,我们能够多容易地找到特定类或模块的测试。除此之外,还有两个其他考虑因素要记住:

  • 这个模块将如何打包和分发?

  • 这段代码将如何投入生产?

对于第一个考虑因素,我们必须记住,我们希望将单元测试与主代码一起分发。对于投入生产的代码,我们可能并不总是希望部署测试,因此我们正在寻找一种将代码和测试分离的方法。

考虑到所有这些因素,有两种流行的测试代码布局方式。

在第一种模式中,测试代码被放置在主代码的子模块中,如下所示:

module
|
+- file
+- file
+- tests
   |
   + test_file
   + test_file

这允许我们通过仅压缩模块目录并将其放入egg文件中来打包模块和测试。整个包是自包含的,可以直接使用。模块通过import module语句访问,测试通过使用import module.tests访问。

运行测试也不需要配置,因为测试可以通过相对导入(如from ..file import class)访问测试代码中的类。

这种模式非常适合将独立模块打包成egg文件并分发。

另一种模式是将测试保留在完全独立的文件夹层次结构中。文件布局可能如下所示:

root
|
+- module
|  |
|  +- file
|  +- file
|
+- tests
   |
   + test_file
   + test_file

前面的模式适用于需要将测试从代码中分离出来的情况。这可能是因为我们正在制作一个产品,不希望向客户发送测试,或者可能是因为我们不希望将测试部署到生产服务器。这种模式适合,因为它很容易将模块单独压缩成egg文件并部署。

这种模式的缺点是,如果项目涉及许多模块,那么分离不同模块的测试可能会很麻烦。一种模式是使用每个目录的不同根,如下所示:

root
|
+- src
|  |
|  +- module1
|     |
|     +- file
|     +- file
|
+- tests
   |
   +- module1
      |
      + test_file
      + test_file

前面的模式在 Java 等语言中很常见,但总体来说相当繁琐且冗长。然而,它可能是解决前面提到的特定需求的一种解决方案。

我们绝对不想做的两种模式如下:

  • 将测试放在与代码相同的文件中

  • 将测试文件放在与代码相同的目录中

将测试放在与代码相同的文件中对于单文件脚本来说是可行的,但对于更大的项目来说则显得杂乱无章。问题是如果文件很长,那么在文件中导航就变得非常困难。而且由于测试通常需要导入其他类,这会污染命名空间。它还增加了循环导入的可能性。最后,由于测试运行器会在文件名中寻找模式,这使得一次性执行所有测试变得困难。

至于将文件放在同一个目录中,这又一次打乱了模块,使得在目录中查找文件变得困难。除了不需要创建单独的测试目录之外,它没有任何特定的优势。避免这种模式,而是创建一个子模块。

命名约定

下一步是确定测试文件、测试类和测试方法的命名约定。

对于测试文件,最好使用test_前缀。例如,名为file1.py的文件将会有其测试在test_file1.py中。这使得我们在查看生产代码时能够轻松地找到相应的测试代码。使用test_前缀是首选的,因为这是大多数测试运行器搜索的默认模式。如果我们使用其他前缀,或者使用后缀如file1_test.py,那么我们很可能需要将额外的配置传递给测试运行器以找到和执行测试。通过坚持大多数常用工具期望的默认约定,我们可以轻松避免这种额外的配置。

例如,我们可以使用以下命令来运行测试:

python3 -m unittest discover

但如果我们想要用后缀_test来命名我们的测试,那么我们必须使用以下命令:

python3 -m unittest discover -p *_test.py

它是可行的,但这只是可以避免的额外配置。我们只有在需要保留旧命名约定的情况下才应该使用它。

那么关于测试类和测试方法呢?unittest模块会查看所有继承自unittest.TestCase的类。因此,类的名称并不重要。然而,其他测试运行器,如nose2,也会根据类的名称来选择测试类。默认模式是搜索以Test结尾的类。因此,将所有测试类命名为以Test结尾是有意义的。这也很有描述性,所以实际上没有很好的理由去做其他的事情。

同样,测试方法应该以test开头。这是测试运行器搜索的默认模式,因此坚持这个约定是有意义的。不以test开头的方法可以用作辅助方法。

测试套件分组

最后,我们来到了一个关于一个类应该包含什么的问题——一个测试类是否应该包含目标类的测试,或者我们应该将所有方法的测试都存储在一个测试类中?在这里,这更多的是一个个人偏好的问题。两者在客观上没有好坏之分,只是取决于哪个更易读。我个人的偏好是在单个代码库中使用这两种模式,根据测试的数量和哪种模式更容易找到我正在寻找的测试。一般来说,如果一个方法有很多测试,那么我会将它们重构到一个单独的测试类中。

使测试可读

在上一节中,我们探讨了相对平凡的问题,即文件布局和命名约定。我们现在将探讨我们可以改进测试用例本身的方法。

我们的首要目标是使理解测试本身变得更简单。没有什么比找到测试用例然后很难弄清楚测试试图做什么更糟糕的了。

我相信我不会是第一个承认自己多次回到一年前自己写的测试,并努力理解自己当时试图做什么的人。

这通常是一个被忽视的领域,因为当我们编写测试时,似乎很明显测试在做什么。我们需要设身处地地想象自己是一个第一次或几年后查看测试的人,试图在没有我们编写测试时的上下文知识的情况下理解测试。这是在处理大型代码库时经常出现的问题。

使用文档字符串

对于一个难以阅读的测试,第一道防线是使用文档字符串。文档字符串是 Python 的一个伟大特性,因为它们在运行时可用。测试运行器通常会拾取文档字符串,并在测试错误和失败时显示它们,这使得从测试报告中直接看到失败内容变得容易。

有些人会说,一份写得好的测试不需要额外的解释。实际上,我们在第三章代码异味和重构中讨论注释的价值时,也说过类似的话。为了重复我们当时的话:解释正在发生什么的注释没有价值,但解释为什么我们以这种方式实现代码的注释是有价值的。同样的原则也适用于文档字符串。

例如,看看以下代码:

    def get_closing_price_list(self, on_date, num_days):
        closing_price_list = []
        for i in range(num_days):
            chk = on_date.date() - timedelta(i)
            for price_event in reversed(self.series):
                if price_event.timestamp.date() > chk:
                    pass
                if price_event.timestamp.date() == chk:
                    closing_price_list.insert(0, price_event)
                    break
                if price_event.timestamp.date() < chk:
                    closing_price_list.insert(0, price_event)
                    break
        return closing_price_list

这是来自TimeSeries类的get_closing_price_list方法,我们在第三章代码异味和重构中对其进行了重构。以下是对该方法的测试:

class TimeSeriesTest(unittest.TestCase):
    def test_closing_price_list_before_series_start_date(self):
        series = TimeSeries()
        series.update(datetime(2014, 3, 10), 5)
        on_date = datetime(2014, 3, 9)
        self.assertEqual([], series.get_closing_price_list(on_date, 1))

此测试检查,如果传递的日期早于时间序列的开始,则返回空列表。从测试中可以清楚地看出这一点。但为什么它返回空列表而不是抛出异常呢?文档字符串是解释这种设计决策的好地方,如下所示:

    def test_closing_price_list_before_series_start_date(self):
        """
        Empty list is returned if on_date is before the start of the
        series
        The moving average calculation might be done before any data
        has been added to the stock. We return an empty list so that
        the calculation can still proceed as usual.
        """
        series = TimeSeries()
        series.update(datetime(2014, 3, 10), 5)
        on_date = datetime(2014, 3, 9)
        self.assertEqual([], series.get_closing_price_list(on_date, 1))

使用固定装置

在查看文档字符串后,我们现在可以将注意力转向测试本身。

如果我们查看单元测试的一般结构,它们通常遵循安排-行动-断言的结构。在这些中,行动部分通常只有几行,断言部分最多也只有几行。到目前为止,测试的最大部分是在安排部分。对于更复杂的测试,其中特定场景可能需要多行来设置,安排部分可能占整个测试的 75%到 80%。

避免重复代码的一种方法是将所有内容都移动到适当的setUptearDown方法中。正如我们在第二章中看到的,“红-绿-重构 – TDD 循环”,unittest提供了三个级别的设置和清理方法:

  • 在每个测试之前和之后运行的setUptearDown

  • 在每个测试类之前和之后运行的setUpClasstearDownClass

  • 在每个测试文件之前和之后运行的setUpModuletearDownModule

这种为测试设置数据的方法被称为固定装置。使用固定装置可以减少测试之间的代码重复,这是一个好主意。然而,也有一些需要注意的缺点:

  • 有时,需要做很多设置,但每个测试只使用整体固定装置的一小部分。在这种情况下,对于新开发者来说,弄清楚每个测试使用固定装置的哪一部分可能会很困惑。

  • 使用类和模块级别的固定装置时,我们必须小心。因为固定装置在多个测试之间共享,我们必须小心不要改变固定装置的状态。如果我们这样做,那么一个测试的结果可能会改变下一个测试的固定装置状态。这可能导致在执行顺序不同时出现非常奇怪的错误。

需要注意的一件事是,如果setUp方法抛出异常,则不会调用tearDown方法。以下是一个示例:

class SomeTest(unittest.TestCase):
    def setUp(self):
        connect_to_database()
        connect_to_server()

    def tearDown(self):
        disconnect_from_database()
        disconnect_from_server()

如果在connect_to_server调用中抛出异常,则不会调用tearDown方法。这将导致数据库连接保持打开状态。当为下一个测试调用setUp时,第一行可能会失败(因为连接已经打开),导致所有其他测试失败。

为了避免这种情况,unittest模块提供了addCleanup方法。此方法接受一个回调函数,无论设置是否通过都会调用,如下所示:

class SomeTest2(unittest.TestCase):
    def setUp(self):
        connect_to_database()
        self.addCleanup(self.disconnect_database)
        connect_to_server()
        self.addCleanup(self.disconnect_server)

    def disconnect_database(self):
        disconnect_from_database()

    def disconnect_server(self):
        disconnect_from_server()

使用这种结构,执行流程如下:

  • 如果数据库调用失败,则不会执行清理操作

  • 如果数据库调用成功了但服务器调用失败了,那么在清理过程中将调用disconnect_database

  • 如果两个调用都成功了,那么在清理过程中将调用disconnect_databasedisconnect_server

我们在什么时候使用addCleanup而不是tearDown?一般来说,当我们访问必须关闭的资源时,addCleanup是最佳选择。tearDown是一个放置其他类型清理的好地方,或者在setUp无法抛出异常的情况下。

固定和打补丁

在使用打补丁的模拟和固定一起使用时,有一个复杂的问题。看看以下代码:

@mock.patch.object(smtplib, "SMTP")
class EmailActionTest(unittest.TestCase):
    def setUp(self):
        self.action = EmailAction(to="siddharta@silverstripesoftware.com")

    def test_connection_closed_after_sending_mail(self, mock_smtp_class):
        mock_smtp = mock_smtp_class.return_value
        self.action.execute("MSFT has crossed $10 price level")
        mock_smtp.send_message.assert_called_with(mock.ANY)
        self.assertTrue(mock_smtp.quit.called)
        mock_smtp.assert_has_calls([
            mock.call.send_message(mock.ANY),
            mock.call.quit()])

这是之前我们查看的EmailAction类的一个测试。在类级别使用patch装饰器来打补丁smtplib.SMTP类,并将模拟对象作为参数传递给所有测试用例。由于patch装饰器的工作方式,它只将模拟对象传递给测试用例方法,这意味着我们无法在setUp方法中访问它。

如果我们看看这个测试,它使用了从mock_smtp_class派生的mock_smtp对象。获取mock_smtp对象的行可以移动到setUp方法中,如果我们能访问到mock_smtp_class的话。有没有一种方法可以在setUp方法中应用补丁,这样我们就可以做一些常见的设置了?

幸运的是,unittest模块为我们提供了完成这项任务的工具。我们不会使用装饰器语法来打补丁,而是会使用以下类似的常规对象语法:

    def setUp(self):
        patcher = mock.patch("smtplib.SMTP")
        self.addCleanup(patcher.stop)
        self.mock_smtp_class = patcher.start()
        self.mock_smtp = self.mock_smtp_class.return_value
        self.action = EmailAction(to="siddharta@silverstripesoftware.com")

我们在这里所做的是将需要打补丁的对象——在这种情况下是smtplib.SMTP——传递给patch函数。这返回一个具有两个方法:startstop的补丁器对象。当我们调用start方法时,补丁被应用,当我们调用stop方法时,补丁被移除。

我们通过将其传递给addCleanup函数来设置patcher.stop方法在测试清理阶段执行。然后我们开始打补丁。start方法返回模拟对象,我们将其用于剩余的设置。

使用这种设置,我们可以在测试中直接使用self.mock_smtp,而无需在每次测试中都从mock_smtp_class获取它。现在的测试看起来如下:

    def test_connection_closed_after_sending_mail(self):
        self.action.execute("MSFT has crossed $10 price level")
        self.mock_smtp.send_message.assert_called_with(mock.ANY)
        self.assertTrue(self.mock_smtp.quit.called)
        self.mock_smtp.assert_has_calls([
            mock.call.send_message(mock.ANY),
            mock.call.quit()])

将这个测试与这一节中较早的测试进行比较。由于我们不再使用装饰器补丁语法,我们不再需要额外的参数。我们也不需要在每个测试中都从mock_smtp_class派生mock_smtp。相反,所有这些工作都在setUp中完成。然后测试可以访问self.mock_smtp并直接使用它。

使用自定义测试用例类层次结构

减少代码重复的另一种方法是我们创建自己的测试类层次结构。例如,如果一个辅助方法在许多测试类中经常被使用,那么我们可以将它提升到更高一级的类中,并从该类继承测试类。以下是一个使概念更清晰的示例:

class MyTestCase(unittest.TestCase):
    def create_start_object(self, value):
        do_something(value)

class SomeTest(MyTestCase):
    def test_1(self):
        create_start_object("value 1")

class SomeTest2(MyTestCase):
    def test_2(self):
        create_start_object("value 2")

    def test_3(self):
        create_start_object("value 3")

在这个例子中,我们创建了一个名为MyTestCase的类,它继承自unittest.TestCase,并在该类中放入了一些辅助方法。实际的测试类继承自MyTestCase,可以访问父类中的辅助方法。

使用这种技术,我们可以将常见的辅助方法组合放入可重用的父类中。层次结构不必只有一层深;有时,我们可能需要为测试的具体应用区域创建更进一步的子类。

在领域附近编写测试

使测试更容易阅读的另一种方法是使用领域语言编写测试,而不是仅仅使用unittest提供的通用函数。在本节中,我们将探讨一些实现这一目标的方法。

编写辅助方法

第一种技术是编写辅助方法。我们在本书中较早地使用了这种方法。以下是一些没有使用辅助方法的测试用例:

    def test_increasing_trend_is_false_if_price_decreases(self):
        timestamps = [datetime(2014, 2, 11), datetime(2014, 2, 12),
                      datetime(2014, 2, 13)]
        prices = [8, 12, 10]
        for timestamp, price in zip(timestamps, prices):
            self.goog.update(timestamp, price)
        self.assertFalse(self.goog.is_increasing_trend())

    def test_increasing_trend_is_false_if_price_equal(self):
        timestamps = [datetime(2014, 2, 11), datetime(2014, 2, 12),
                      datetime(2014, 2, 13)]
        prices = [8, 10, 10]
        for timestamp, price in zip(timestamps, prices):
            self.goog.update(timestamp, price)
        self.assertFalse(self.goog.is_increasing_trend())

虽然测试用例很短,但测试中实际发生的事情并不清晰。让我们将其中一些代码移动到辅助方法中,如下所示:

    def given_a_series_of_prices(self, prices):
        timestamps = [datetime(2014, 2, 10), datetime(2014, 2, 11),
                      datetime(2014, 2, 12), datetime(2014, 2, 13)]
        for timestamp, price in zip(timestamps, prices):
        self.goog.update(timestamp, price)

以下是用新辅助方法编写的相同两个测试用例:

    def test_increasing_trend_is_false_if_price_decreases(self):
        self.given_a_series_of_prices([8, 12, 10])
        self.assertFalse(self.goog.is_increasing_trend())

    def test_increasing_trend_is_false_if_price_equal(self):
        self.given_a_series_of_prices([8, 10, 10])
        self.assertFalse(self.goog.is_increasing_trend())

如我们所见,测试现在清晰多了。这是因为辅助方法清楚地表达了计算的意图,使得新开发者能够将测试用例中的步骤与他们对需求的心理模型相对应。

编写更好的断言

提高测试可读性的简单方法是编写我们自己的断言方法,这些方法比unittest提供的通用断言级别更高。例如,假设我们想要编写一个测试来验证股票的价格历史。以下是这样测试可能看起来:

class TimeSeriesEqualityTest(unittest.TestCase):
    def test_timeseries_price_history(self):
        series = TimeSeries()
        series.update(datetime(2014, 3, 10), 5)
        series.update(datetime(2014, 3, 11), 15)
        self.assertEqual(5, series[0].value)
        self.assertEqual(15, series[1].value)

现在,编写测试的另一种方式如下:

class TimeSeriesTestCase(unittest.TestCase):
    def assert_has_price_history(self, price_list, series):
        for index, expected_price in enumerate(price_list):
            actual_price = series[index].value
            if actual_price != expected_price:
                raise self.failureException("[%d]: %d != %d".format(index, expected_price, actual_price))

class TimeSeriesEqualityTest(TimeSeriesTestCase):
    def test_timeseries_price_history(self):
        series = TimeSeries()
        series.update(datetime(2014, 3, 10), 5)
        series.update(datetime(2014, 3, 11), 15)
        self.assert_has_price_history([5, 15], series)

在前面的例子中,我们创建了自己的基测试类和一个自定义的断言方法。测试用例继承自这个基测试类,并在测试中使用这个断言。

assert_has_price_history方法的实现给出了一种如何简单编写我们自己的断言方法的思路。我们只需实现我们的断言逻辑,并在断言应该表示测试失败时引发self.failureExceptionself.failureExceptionunittest.TestCase的一个属性,通常设置为AssertionError异常。我们可以自己引发AssertionError,但unittest模块允许我们使用不同的异常进行配置,因此最好引发self.failureException,它总是设置为正确的值用于使用。

当在多个测试中反复使用相同的断言序列时,我们应该看看是否有机会用更清晰地表达我们意图的高级断言来替换内置断言的调用。

使用自定义的相等性检查器

assertEqual 方法的酷特性是它根据被比较的对象类型给出自定义的失败消息。如果我们尝试断言两个整数,我们会得到以下结果:

>>> import unittest
>>> testcase = unittest.TestCase()
>>> testcase.assertEqual(1, 2)
Traceback (most recent call last):
 ...
AssertionError: 1 != 2

另一方面,断言列表会给我们另一个消息,显示预期列表和实际列表之间的差异,如下所示:

>>> import unittest
>>> testcase = unittest.TestCase()
>>> testcase.assertEqual([1, 2], [1, 3])
Traceback (most recent call last):
 ...
AssertionError: Lists differ: [1, 2] != [1, 3]

First differing element 1:
2
3

- [1, 2]
?     ^

+ [1, 3]
?     ^

在幕后,assertEqual 根据被比较的对象类型委托给不同的函数。这就是我们如何为大多数常见的内置数据结构(如字符串、序列、列表、元组、集合和字典)获得具体和相关的相等性检查。

幸运的是,这个灵活的系统对开发者开放,这意味着我们可以为我们的应用程序对象添加自己的相等性检查器。以下是我们尝试比较两个 Stock 对象的默认场景:

>>> import unittest
>>> from stock_alerter.stock import Stock
>>> test_case = unittest.TestCase()
>>> stock_1 = Stock("GOOG")
>>> stock_2 = Stock("GOOG")
>>> test_case.assertEqual(stock_1, stock_2)
Traceback (most recent call last):
 ...
AssertionError: <stock_alerter.stock.Stock object at 0x000000000336EDD8> != <stock_alerter.stock.Stock object at 0x00000000033E9588>

断言失败,因为尽管两个对象包含相同的数据,但在内存中它们仍然是不同的对象。现在让我们尝试为 Stock 类注册我们自己的相等性函数,该函数仅比较符号以识别 Stock 对象之间的相等性。我们只需使用 addTypeEqualityFunc 方法注册我们的检查器,如下所示:

>>> test_case.addTypeEqualityFunc(Stock, lambda stock_1, stock_2, msg: stock_1.symbol == stock_2.symbol)
>>> test_case.assertEqual(stock_1, stock_2)
>>> print(test_case.assertEqual(stock_1, stock_2))
None
>>>

检查相等性的函数接受三个参数:第一个对象、第二个对象以及用户传递给 assertEqual 的可选消息。一旦我们以这种方式注册了函数,我们就可以调用 assertEqual 并传入两个 Stock 对象,assertEqual 将将比较委托给我们所注册的函数。

以这种方式使用相等性函数是断言单元测试代码中应用域对象的一种好方法。尽管如此,这种方法有两个限制:

  • 我们必须为给定类型使用相同的比较函数。我们无法在某些测试中使用一个比较函数,而在其他测试中使用另一个比较函数。

  • assertEqual 的两个参数都必须是该类型的对象。我们无法传入不同类型的两个对象。

这两个限制都可以通过使用匹配器来克服,这就是我们现在将注意力转向的地方。

使用匹配器

使断言更易读的第三种方法是创建自定义匹配器对象,以便在断言期间使比较更易读。我们在之前为 EmailAction 类编写测试时看到了使用匹配器的一瞥。以下是对该匹配器再次的查看:

class MessageMatcher:
    def __init__(self, expected):
        self.expected = expected

    def __eq__(self, other):
        return self.expected["Subject"] == other["Subject"] and \
            self.expected["From"] == other["From"] and \
            self.expected["To"] == other["To"] and \
            self.expected["Message"] == other._payload

匹配器可以是任何实现了 __eq__ 方法的类。该方法将实际对象作为参数,并且该方法可以实现所需的任何比较逻辑。使用这种方法,我们可以在断言中直接比较域对象,而无需用多个单独的断言来杂乱无章。

匹配器不需要比较完整的域对象。我们可以仅比较我们感兴趣的属性。实际上,我们可以创建不同的匹配器来匹配特定的对象子集。例如,我们可能会创建一个 AlertMessageMatcher,如下所示:

class AlertMessageMatcher:
    def __init__(self, expected):
        self.expected = expected

    def __eq__(self, other):
        return self.expected["Subject"] == "New Stock Alert" and \
            self.expected["From"] == other["From"] and \
            self.expected["To"] == other["To"] and \
            self.expected["Message"] == other._payload

这个匹配器只会匹配具有给定主题的警报消息,同时从预期对象中获取其他参数。

摘要

在本章中,你更详细地探讨了保持测试可维护性的重要但常被忽视的主题。你研究了保持一致的测试文件布局方案的重要性以及各种替代方案的优缺点。你研究了测试的命名和分组,然后转向使测试更容易理解的主题。我们讨论的一些策略包括使用文档字符串、创建自定义测试类层次结构和利用固定装置。最后,你研究了通过使用辅助函数、自定义断言、等价函数和编写自定义匹配器来使代码更易于阅读。

在下一章中,你将学习如何使用doctest模块将测试纳入你的文档中。

第七章. 使用 doctest 的可执行文档

在整本书中,我们强调了代码尽可能自文档化的必要性。我们提到了 Python 的酷炫 docstring 特性如何帮助我们实现这一目标。一般来说,文档存在一个问题——它很容易与代码不同步。很多时候我们看到代码有所改变,但相应的文档更改并未进行,导致新开发者对代码的实际工作方式感到困惑。现在,doctest模块来拯救我们。

doctest模块允许我们在 docstrings 中指定示例。然后模块提取示例,运行它们,并验证它们是否仍然有效。

我们的第一份 doctest

以下是在Stock类中price方法的当前版本:

    def price(self):
        try:
            return self.history[-1].value
        except IndexError:
            return None

现在,在 docstring 中,我们添加了如何使用此方法的示例。这些示例基本上是 Python 交互式 shell 的复制粘贴。因此,包含要执行的输入的行以>>>提示符开头,而没有提示符的行表示输出,如下所示:

    def price(self):
        """Returns the current price of the Stock

        >>> from datetime import datetime
        >>> stock = Stock("GOOG")
        >>> stock.update(datetime(2011, 10, 3), 10)
        >>> stock.price
        10
        """
        try:
            return self.history[-1].value
        except IndexError:
            return None

现在我们有了 docstring,我们需要一种执行它的方法。将以下行添加到文件底部:

if __name__ == "__main__":
    import doctest
    doctest.testmod()

运行 doctest

现在,我们可以通过将文件作为模块执行来运行测试。我们需要将文件作为模块执行,以便相对导入工作。如果这是一个独立的脚本,或者我们使用了绝对导入而不是相对导入,那么我们就可以直接执行该文件。由于上面的代码位于stock.py文件中,我们必须执行stock_alerter.stock模块。以下是要执行的命令:

  • Windows: python.exe -m stock_alerter.stock

  • Linux/Mac: python3 -m stock_alerter.stock

当我们运行上述命令时,我们会得到没有任何输出的输出。是的,什么都没有。如果没有输出,那么这意味着所有的 doctests 都通过了。我们可以传递-v命令行参数(用于详细输出)来查看测试确实通过了,如下所示:

  • Windows: python.exe -m stock_alerter.stock -v

  • Linux/Mac: python3 -m stock_alerter.stock -v

当我们这样做时,我们会得到以下输出:

Trying:
 from datetime import datetime
Expecting nothing
ok
Trying:
 stock = Stock("GOOG")
Expecting nothing
ok
Trying:
 stock.update(datetime(2011, 10, 3), 10)
Expecting nothing
ok
Trying:
 stock.price
Expecting:
 10
ok
8 items had no tests:
 __main__
 __main__.Stock
 __main__.Stock.__init__
 __main__.Stock._is_crossover_below_to_above
 __main__.Stock.get_crossover_signal
 __main__.Stock.is_increasing_trend
 __main__.Stock.update
 __main__.StockSignal
1 items passed all tests:
 4 tests in __main__.Stock.price
4 tests in 9 items.
4 passed and 0 failed.
Test passed.

让我们更详细地看看这个输出。

如果我们看我们的示例的第一行,它包含以下内容:

>>> from datetime import datetime

doctest会提取这一行并评估输出:

Trying:
 from datetime import datetime
Expecting nothing
ok

示例的下一行是另一个以>>>提示符开始的输入行,因此 doctest 认为执行第一行不应有任何输出,因此有语句Expecting nothing

当测试的第一行执行时,确实没有打印任何内容,所以doctest给出输出ok,这意味着该行按预期执行。然后doctest继续到下一行,并遵循相同的程序,直到遇到以下行:

>>> stock.price
10

我们的测试表明,当这一行执行时,应该打印出10。这正是 doctest 所检查的,如下所示:

Trying:
 stock.price
Expecting:
 10
ok

注意

注意,我们没有明确调用print(stock.price)。我们只是放置了stock.price并期望输出为10。这正是我们在 Python 交互式外壳中看到的行为;doctest使用相同的行为。

在这一行之后,我们的示例结束,doctest继续到下一个方法,如下所示:

8 items had no tests:
 __main__
 __main__.Stock
 __main__.Stock.__init__
 __main__.Stock._is_crossover_below_to_above
 __main__.Stock.get_crossover_signal
 __main__.Stock.is_increasing_trend
 __main__.Stock.update
 __main__.StockSignal
1 items passed all tests:
 4 tests in __main__.Stock.price
4 tests in 9 items.
4 passed and 0 failed.
Test passed.

它告诉我们剩余的方法没有测试,并且所有的测试都通过了。请注意,doctest将示例的每一行都视为一个单独的测试,这就是为什么它识别出四个测试的原因。由于 Python 支持模块级和类级文档字符串,我们也可以在那些地方放置一些示例,比如如何使用整个模块或类的示例。这就是为什么doctest也告诉我们__main____main__.Stock没有任何测试。

doctests 的美丽之处在于它允许我们在示例之间混合文档。这允许我们像以下这样扩展price方法的文档字符串:

    def price(self):
        """Returns the current price of the Stock

        >>> from datetime import datetime
        >>> stock = Stock("GOOG")
        >>> stock.update(datetime(2011, 10, 3), 10)
        >>> stock.price
        10

        The method will return the latest price by timestamp, so even if updates are out of order, it will return the latest one

        >>> stock = Stock("GOOG")
        >>> stock.update(datetime(2011, 10, 3), 10)

        Now, let us do an update with a date that is earlier than the
        previous one

        >>> stock.update(datetime(2011, 10, 2), 5)

        And the method still returns the latest price

        >>> stock.price
        10

        If there are no updates, then the method returns None

        >>> stock = Stock("GOOG")
        >>> print(stock.price)
        None
        """
        try:
            return self.history[-1].value
        except IndexError:
            return None

运行上面的代码,它应该通过以下新的输出:

Trying:
 stock = Stock("GOOG")
Expecting nothing
ok
Trying:
 stock.update(datetime(2011, 10, 3), 10)
Expecting nothing
ok
Trying:
 stock.update(datetime(2011, 10, 2), 5)
Expecting nothing
ok
Trying:
 stock.price
Expecting:
 10
ok
Trying:
 stock = Stock("GOOG")
Expecting nothing
ok
Trying:
 print(stock.price)
Expecting:
 None
ok

如我们所见,doctest遍历文档并识别出需要执行的确切行。这允许我们在代码片段之间放置解释和文档。结果?良好的解释文档加上可测试的代码。这是一个绝佳的组合!

让我们快速看一下最后一个例子:

>>> stock = Stock("GOOG")
>>> print(stock.price)
None

如果你注意到,我们明确地打印了输出。原因是 Python 交互式外壳通常在值是None时不会给出任何输出。由于 doctest 模仿了交互式外壳的行为,我们本可以只留一个空行,测试就会通过,但这并不清楚发生了什么。所以,我们调用 print 来明确表示我们期望输出为None

测试失败

现在我们来看一下测试失败的样子。以下是对is_increasing_trend方法的 doctest:

    def is_increasing_trend(self):
        """Returns True if the past three values have been strictly
        increasing

        Returns False if there have been less than three updates so far

        >>> stock = Stock("GOOG")
        >>> stock.is_increasing_trend()
        False
        """

        return self.history[-3].value < \
            self.history[-2].value < self.history[-1].value

运行测试时,我们得到以下结果:

Failed example:
 stock.is_increasing_trend()
Exception raised:
 Traceback (most recent call last):
 File "C:\Python34\lib\doctest.py", line 1324, in __run
 compileflags, 1), test.globs)
 File "<doctest __main__.Stock.is_increasing_trend[1]>", line 1, in <module>
 stock.is_increasing_trend()
 File "c:\Projects\tdd_with_python\src\stock_alerter\stock.py", line 91, in is_increasing_trend
 return self.history[-3].value < \
 File "c:\Projects\tdd_with_python\src\stock_alerter\timeseries.py", line 13, in __getitem__
 return self.series[index]
 IndexError: list index out of range
**********************************************************************
1 items had failures:
 1 of   2 in __main__.Stock.is_increasing_trend
***Test Failed*** 1 failures.

doctest告诉我们导致失败的是哪一行。它还告诉我们执行了哪个命令,以及发生了什么。我们可以看到,一个意外的异常导致测试失败。

我们现在可以像以下这样修复代码:

    def is_increasing_trend(self):
        """Returns True if the past three values have been strictly increasing

        Returns False if there have been less than three updates so far

        >>> stock = Stock("GOOG")
        >>> stock.is_increasing_trend()
        False
        """

        try:
            return self.history[-3].value < \
               self.history[-2].value < self.history[-1].value
        except IndexError:
            return True

异常现在已经消失了,但我们在修复中有一个 bug,因为它已经被替换为失败,如下所示:

Failed example:
 stock.is_increasing_trend()
Expected:
 False
Got:
 True

让我们修复它:

    def is_increasing_trend(self):
        """Returns True if the past three values have been strictly increasing
        Returns False if there have been less than three updates so far

        >>> stock = Stock("GOOG")
        >>> stock.is_increasing_trend()
        False
        """

        try:
            return self.history[-3].value < \
               self.history[-2].value < self.history[-1].value
        except IndexError:
            return False

通过这次修复,所有的测试又都通过了。

测试异常

update方法应该在价格小于零时也引发ValueError。以下是在 doctest 中验证这一点的方法:

    def update(self, timestamp, price):
        """Updates the stock with the price at the given timestamp

        >>> from datetime import datetime
        >>> stock = Stock("GOOG")
        >>> stock.update(datetime(2014, 10, 2), 10)
        >>> stock.price
        10

        The method raises a ValueError exception if the price is negative

        >>> stock.update(datetime(2014, 10, 2), -1)
        Traceback (most recent call last):
            ...
        ValueError: price should not be negative
        """

        if price < 0:
            raise ValueError("price should not be negative")
        self.history.update(timestamp, price)
        self.updated.fire(self)

下一个部分展示了doctest期望查看的内容:

Traceback (most recent call last):
    ...
ValueError: price should not be negative

预期的输出以常规的 traceback 输出开始。这一行告诉doctest期望一个异常。之后是实际的 traceback。由于输出通常包含可能会改变的文件路径,因此很难完全匹配。幸运的是,我们不需要这样做。doctest允许我们使用三个缩进的点来表示 traceback 的中间部分。最后,最后一行显示了预期的异常和异常信息。这是匹配的行,用来查看测试是否通过。

包级别 doctests

如我们所见,doctests 可以针对方法、类和模块编写。然而,它们也可以针对整个包编写。通常,这些会放在包的__init__.py文件中,并展示整个包应该如何工作,包括多个相互作用的类。以下是从我们的__init__.py文件中的一个这样的 doctests 集:

r"""
The stock_alerter module allows you to set up rules and get alerted when those rules are met.

>>> from datetime import datetime

First, we need to setup an exchange that contains all the stocks that are going to be processed. A simple dictionary will do.

>>> from stock_alerter.stock import Stock
>>> exchange = {"GOOG": Stock("GOOG"), "AAPL": Stock("AAPL")}

Next, we configure the reader. The reader is the source from where the stock updates are coming. The module provides two readers out of the box: A FileReader for reading updates from a comma separated file, and a ListReader to get updates from a list. You can create other readers, such as an HTTPReader, to get updates from a remote server.
Here we create a simple ListReader by passing in a list of 3-tuples containing the stock symbol, timestamp and price.

>>> from stock_alerter.reader import ListReader
>>> reader = ListReader([("GOOG", datetime(2014, 2, 8), 5)])

Next, we set up an Alert. We give it a rule, and an action to be taken when the rule is fired.

>>> from stock_alerter.alert import Alert
>>> from stock_alerter.rule import PriceRule
>>> from stock_alerter.action import PrintAction
>>> alert = Alert("GOOG > $3", PriceRule("GOOG", lambda s: s.price > 3),\
...               PrintAction())

Connect the alert to the exchange

>>> alert.connect(exchange)

Now that everything is setup, we can start processing the updates

>>> from stock_alerter.processor import Processor
>>> processor = Processor(reader, exchange)
>>> processor.process()
GOOG > $3
"""

if __name__ == "__main__":
    import doctest
    doctest.testmod()

我们可以像下面这样运行它:

  • Windows: python.exe -m stock_alerter.__init__

  • Linux/Mac: python3 -m stock_alerter.__init__

当我们这样做时,测试会通过。

关于这个测试,有几个需要注意的事项:

在 doctests 中,我们使用绝对导入而不是相对导入。例如,我们说from stock_alerter.stock import Stock而不是from .stock import Stock。这使我们能够轻松地从命令行运行 doctests。运行此 doctests 的另一种方法是:

  • Windows: python.exe -m doctest stock_alerter\__init__.py

  • Linux/Mac: python3 -m doctest stock_alerter\__init__.py

这种语法仅在文件使用绝对导入时才有效。否则,我们会得到错误SystemError: Parent module '' not loaded, cannot perform relative import

通常,在进行包级别的 doctests 时,建议使用绝对导入。

除了这些,一些示例也跨越了多行。以下是一个这样的示例:

>>> alert = Alert("GOOG > $3", PriceRule("GOOG", lambda s: s.price > 3),\
...               PrintAction())

支持多行的方法与交互式 shell 中的方法相同。在行尾使用反斜杠\,并在下一行开头使用三个点...。这被doctest解释为行续行,并将这两行合并为单个输入。

注意

一个重要的注意事项:注意,文档字符串以r前缀开始,如下所示r"""。这表示原始字符串。如上所述,我们在几个地方使用了反斜杠来表示输入的续行。当 Python 在字符串中找到一个反斜杠时,它将其解释为转义字符而不是字面反斜杠。解决方案是使用双反斜杠\\来转义反斜杠,或者使用不进行反斜杠解释的原始字符串。与其在所有地方都使用双反斜杠,不如使用带有r前缀标记的原始字符串来标记文档字符串的开始,这样更可取。

维护 doctests

Doctests 可能会非常冗长,通常包含大量的解释和示例混合在一起。这些 doctests 很容易扩展到多页。有时,可能会有很多行 doctests 后面只跟着几行代码。我们可以在update方法中看到这种情况。这可能会使代码导航变得更加困难。

我们可以通过将 doctests 放入单独的文件来解决这个问题。假设,我们将文档字符串的内容放入一个名为readme.txt的文件中。然后我们像下面这样更改我们的__init__.py文件:

if __name__ == "__main__":
    import doctest
    doctest.testfile("readme.txt")

现在将加载readme.txt的内容并将其作为 doctests 运行。

当在外部文件中编写测试时,没有必要像在 Python 文件中那样在内容周围放置引号。整个文件内容都被视为 doctests。同样,我们也不需要转义反斜杠。

这个特性使得将所有 doctests 放入单独的文件变得实用。这些文件应该作为用户文档使用,并包含其中的 doctests。这样,我们就可以避免在代码中添加大量 doctrings 而造成混乱。

运行一系列 doctests

doctest模块缺失的一个特性是有效的自动发现机制。与unittest模块不同,后者会搜索所有文件以查找测试并运行它们,而 doctest 则需要我们显式地在命令行上执行每个文件。这对大型项目来说是一个大麻烦。

虽然有一些方法可以实现这一点。最直接的方法是将 doctests 包装在unittest.TestCase类中,如下所示:

import doctest
import unittest
from stock_alerter import stock

class PackageDocTest(unittest.TestCase):
    def test_stock_module(self):
        doctest.testmod(stock)

    def test_doc(self):
        doctest.testfile(r"..\readme.txt")

这些 doctests 可以像通常一样与单元测试一起运行。

这确实可行,但问题是如果 doctests 中发生失败,测试不会失败。错误会被打印出来,但不会记录失败。如果手动运行测试,这没问题,但如果以自动化的方式运行测试,例如作为构建或部署过程的一部分,就会造成问题。

doctest还有一个特性,可以通过它将 doctests 包装在unittest中:

import doctest
from stock_alerter import stock

def load_tests(loader, tests, pattern):
    tests.addTests(doctest.DocTestSuite(stock))
    tests.addTests(doctest.DocFileSuite("../readme.txt"))
    return tests

我们之前没有看过load_tests,现在让我们快速看一下。load_tests是由unittest模块用来从当前模块加载单元测试套件的。当这个函数不存在时,unittest会使用其默认方法通过查找继承自unittest.TestCase的类来加载测试。然而,当这个函数存在时,它会被调用,并且可以返回一个与默认不同的测试套件。然后返回的套件会被运行。

由于 doctests 不是unittest.TestCase的一部分,因此默认情况下不会在执行单元测试时运行。我们做的是实现load_tests函数,并在该函数中将 doctests 添加到测试套件中。我们使用doctest.DocTestSuitedoctest.DocFileSuite方法从 doctests 创建与unittest兼容的测试套件。然后我们将这些测试套件追加到load_tests函数中要执行的总体测试中。

doctest.DocTestSuite 接收包含测试的模块作为参数。

注意

注意,我们必须传入实际的模块对象,而不仅仅是字符串。

doctest.DocFileSuite 接收包含 doctests 的文件名。文件名相对于当前测试模块的目录。例如,如果我们的目录结构如下所示:

src
|
+- stock_alerter
   |
   +- readme.txt
   +- tests
      |
      +- test_doctest.py

然后,我们将在 test_doctest.py 中使用路径 ../readme.txt 来引用此文件。

或者,我们可以指定一个包名,路径可以相对于该包,如下所示:

tests.addTests(doctest.DocFileSuite("readme.txt",
                                    package="stock_alerter"))

设置和拆卸

doctests 的一个问题是我们必须显式设置 docstring 内部的所有内容。例如,以下是我们之前编写的 update 方法的 doctest:

>>> from datetime import datetime
>>> stock = Stock("GOOG")
>>> stock.update(datetime(2011, 10, 3), 10)
>>> stock.price
10

在第一行,我们导入 datetime 模块。这与示例无关,会使示例变得杂乱,但我们必须添加它,否则我们将得到以下错误:

Failed example:
 stock.update(datetime(2011, 10, 3), 10)
Exception raised:
 Traceback (most recent call last):
 ...
 NameError: name 'datetime' is not defined

有没有避免这些行重复的方法?是的,有。

DocFileSuiteDocTestSuite 都接受一个 globs 参数。此参数接受一个字典,其中包含用于 doctests 的全局变量项,它们可以通过示例访问。以下是我们如何做到这一点:

import doctest
from datetime import datetime
from stock_alerter import stock

def load_tests(loader, tests, pattern):
    tests.addTests(doctest.DocTestSuite(stock, globs={
        "datetime": datetime,
        "Stock": stock.Stock
    }))
    tests.addTests(doctest.DocFileSuite("readme.txt", package="stock_alerter"))
    return tests

注意

注意,我们必须传入的不仅是 datetime 模块,还有 Stock 类。默认情况下,doctest 使用模块自己的全局变量在执行上下文中。这就是为什么我们之前能够在 doctests 中使用 Stock 类。当我们通过 globs 参数替换执行上下文时,我们必须显式设置 Stock 对象为执行上下文的一部分。

DocFileSuiteDocTestSuite 也接受 setUptearDown 参数。这些参数接受一个函数,该函数将在每个 doctest 之前和之后被调用。这是一个执行任何测试所需的环境设置或拆卸的好地方。该函数还传递了一个 DocTest 对象,可以在设置和拆卸过程中使用。DocTest 对象有许多属性,但最常用的是 globs 属性。这是执行上下文的字典,可以在设置中添加以实例化将在对象之间重用的对象。以下是一个这样的使用示例:

import doctest
from datetime import datetime
from stock_alerter import stock

def setup_stock_doctest(doctest):
    s = stock.Stock("GOOG")
    doctest.globs.update({"stock": s})

def load_tests(loader, tests, pattern):
    tests.addTests(doctest.DocTestSuite(stock, globs={
        "datetime": datetime,
        "Stock": stock.Stock
    }, setUp=setup_stock_doctest))
    tests.addTests(doctest.DocFileSuite("readme.txt", package="stock_alerter"))
    return tests

通过实例化和将股票传递给 doctests,我们可以消除在单个测试中实例化它的需要,因此测试最初如下所示:

    def is_increasing_trend(self):
        """Returns True if the past three values have been strictly
        increasing

        Returns False if there have been less than three updates so far

        >>> stock = Stock("GOOG")
        >>> stock.is_increasing_trend()
        False
        """

现在测试变为以下内容:

    def is_increasing_trend(self):
        """Returns True if the past three values have been strictly
        increasing

        Returns False if there have been less than three updates so far

        >>> stock.is_increasing_trend()
        False
        """

为什么我们通过 setUp 函数实例化和传递 stock 而不是使用 glob 参数?原因是我们想要为每个测试创建一个新的 Stock 实例。由于 setUptearDown 在每个测试之前被调用,因此每次都会将一个新的 stock 实例添加到 doctest.glob 中。

doctest 的限制

doctest 的最大限制是它只比较打印的输出。这意味着任何可能变化的输出都会导致测试失败。以下是一个示例:

>>> exchange
{'GOOG': <stock_alerter.stock.Stock object at 0x00000000031F8550>, 'AAPL': <stock_alerter.stock.Stock object at 0x00000000031F8588>}

此 doctest 有可能因为两个原因而失败:

  • Python 不保证字典对象打印的顺序,这意味着它可能以相反的顺序打印出来,有时会导致失败

  • Stock 对象的地址每次可能都不同,因此这部分在下次测试运行时将无法匹配

第一个问题的解决方案是确保输出是确定的。例如,以下方法将有效:

>>> for key in sorted(exchange.keys()):
...    print(key, exchange[key])
...
AAPL <stock_alerter.stock.Stock object at 0x00000000031F8550>
GOOG <stock_alerter.stock.Stock object at 0x00000000031F8588>

尽管如此,仍然存在对象地址的问题。为了解决这个问题,我们需要使用 doctest 指令。

Doctest 指令

doctest 支持许多指令,这些指令会改变模块的行为。

我们将要查看的第一个指令是 ELLIPSIS。此指令允许我们使用三个点 ... 来匹配任何文本。我们可以使用它来匹配对象地址,如下所示:

>>> for key in sorted(exchange.keys()): #doctest: +ELLIPSIS
...    print(key, exchange[key])
...
AAPL <stock_alerter.stock.Stock object at 0x0...>
GOOG <stock_alerter.stock.Stock object at 0x0...>

现在示例将通过。

... 将匹配运行时打印的任何地址。我们通过在示例中添加注释 #doctest: +ELLIPSIS 来启用此指令。这将仅为此示例启用指令。同一 doctest 中的后续示例将关闭,除非它们被特别启用。

一些常用的指令包括:

  • NORMALIZE_WHITESPACE: 默认情况下,doctest 会精确匹配空白。一个空格不会与制表符匹配,并且换行符不会匹配,除非它们位于完全相同的位置。有时,我们可能想要通过换行或缩进来美化预期的输出,使其更容易阅读。在这种情况下,可以将 NORMALIZE_WHITESPACE 指令设置为 doctest 将所有空白视为相等。

  • IGNORE_EXCEPTION_DETAIL: 当匹配异常时,doctest 会查看异常的类型以及异常消息。当此指令启用时,仅检查类型是否匹配。

  • SKIP: 带有此指令的示例将被完全跳过。这可能是因为文档有意显示一个不工作或输出随机的示例。它也可以用来注释掉不工作的 doctests。

  • REPORT_ONLY_FIRST_FAILURE: 默认情况下,doctest 在失败后将继续执行后续示例,并将报告这些示例的失败。很多时候,一个示例的失败会导致后续示例的失败,并可能导致许多错误报告,这使得很难识别导致所有其他失败的第一个失败的示例。此指令将仅报告第一个失败。

这不是指令的完整列表,但它们涵盖了最常用的指令。

可以在单独的行上给出多个指令,或者用逗号分隔。以下将有效:

>>> for key in sorted(exchange.keys()):
...    print(key, exchange[key])
...    #doctest: +ELLIPSIS
...    #doctest: +NORMALIZE_WHITESPACE
AAPL       <stock_alerter.stock.Stock object at 0x0...>
GOOG       <stock_alerter.stock.Stock object at 0x0...>

或者,以下也可以工作:

>>> for key in sorted(exchange.keys()):
...    print(key, exchange[key])
...    #doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
AAPL       <stock_alerter.stock.Stock object at 0x0...>
GOOG       <stock_alerter.stock.Stock object at 0x0...>

指令也可以通过 optionflags 参数传递给 DocFileSuiteDocTestSuite。当以以下方式传递时,指令对整个文件或模块生效:

options = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE
tests.addTests(doctest.DocFileSuite("readme.txt",
                                    package="stock_alerter",
                                    optionflags=options))

在 doctest 中,我们可以根据需要关闭某些指令,如下所示:

>>> for key in sorted(exchange.keys()):
...    print(key, exchange[key])
... #doctest: -NORMALIZE_WHITESPACE
AAPL <stock_alerter.stock.Stock object at 0x0...>
GOOG <stock_alerter.stock.Stock object at 0x0...>

使用指令是选择性地启用或禁用 doctests 中特定行为的好方法。

doctests 如何与 TDD 流程相结合?

现在我们对 doctests 有了一个相当好的了解,接下来的问题是:这如何与 TDD 流程相结合?记住,在 TDD 流程中,我们首先编写测试,然后编写实现。doctests 是否适合这个流程?

在某种程度上,是的。doctests 并不适合用于单个方法的 TDD。对于这些,unittest 模块是更好的选择。doctest 发挥作用的地方在于包级别的交互。穿插着示例的解释真正展示了包内不同模块和类之间的交互。这样的 doctests 可以在开始时编写出来,为我们想要整个包如何工作提供一个高级概述。这些测试将会失败。随着单个类和方法被编写,测试将开始通过。

摘要

在本章中,你了解了 Python 的 doctest 模块。你看到了它是如何帮助你将示例嵌入到文档字符串中的。你查看了几种编写 doctests 的方法,包括方法和包文档字符串。你还看到了如何将包级别的 doctests 移动到单独的文件中并运行它们。维护 doctests 很重要,你查看了一些更好的维护 doctests 的方法,包括使用设置和清理以及将它们包含在常规测试套件中。最后,你查看了一些限制以及如何使用指令来克服一些限制。

在下一章中,你将通过查看 nose2 包来首次了解第三方工具。

第八章:使用 nose2 扩展 unittest

到目前为止,我们一直在使用unittest测试运行器来运行我们的测试。Python 社区已经创建了许多其他第三方测试运行器。其中最受欢迎的一个是 nose2。nose2 提供了额外的功能,这些功能在默认测试运行器的基础上进行了改进。

开始使用 nose2

安装 nose2 非常简单。最简单的方法是使用 pip 通过以下命令安装它:

pip install nose2

现在我们使用 nose2 来运行我们的测试。从 stock alerter 项目目录中,运行nose2命令(我们可能需要先将其添加到路径中)。nose2 默认具有测试自动发现功能,因此只需运行命令就应该会得到以下输出:

...............................................................
----------------------------------------------------------------------
Ran 63 tests in 0.109s

OK

如我们所见,nose2命令给出了与unittest相同的输出。nose2 也发现了相同的测试并运行了它们。默认情况下,nose2 的自动发现模式与unittest兼容,因此我们可以直接将 nose2 作为替换运行器使用,而无需更改任何代码。

为 nose2 编写测试

除了拾取使用unittest模块编写的现有测试并运行它们之外,nose2 还支持编写测试的新方法。

首先,nose2 允许测试是常规函数。我们不需要创建一个类并从任何基类继承。只要函数以单词test开头,它就被认为是测试并执行。

我们可以采用以下测试:

class StockTest(unittest.TestCase):
    def setUp(self):
        self.goog = Stock("GOOG")

    def test_price_of_a_new_stock_class_should_be_None(self):
        self.assertIsNone(self.goog.price)

并将上述测试按以下方式编写:

def test_price_of_a_new_stock_class_should_be_None():
    goog = Stock("GOOG")
    assert goog.price is None

如我们所见,以这种方式编写测试减少了我们之前必须做的某些样板代码:

  • 我们不再需要创建一个类来存放测试

  • 我们不再需要从任何基类继承

  • 我们甚至不需要导入unittest模块

我们只需将测试编写为常规函数,nose2 就会自动发现并运行测试。

除了将测试移动到常规函数之外,我们还做了一项其他更改,那就是我们断言预期结果的方式。

之前,我们做了以下操作:

self.assertIsNone(self.goog.price)

当测试是一个函数时,我们执行以下操作:

assert goog.price is None

为什么做这个更改?unittest.TestCase类提供了许多内置的断言方法。当我们从这个类继承时,我们可以在测试中使用这些方法。当我们以函数的形式编写测试时,我们就不再能够访问这些方法。幸运的是,nose2 支持 Python 的内置assert语句,因此我们可以在测试中使用它。

assert语句也支持像以下这样的消息参数:

assert goog.price is None, "Price of a new stock should be None"

如果测试失败,消息将按以下方式打印到输出:

======================================================================
FAIL: stock_alerter.tests.test_stock.FunctionTestCase (test_price_of_a_new_stock_class_should_be_None)
----------------------------------------------------------------------
Traceback (most recent call last):
 ...
 assert goog.price is None, "Price of a new stock should be None"
AssertionError: Price of a new stock should be None

----------------------------------------------------------------------

设置和清理

nose2 还支持为函数式测试用例提供设置和清理。这是通过在函数对象上设置setupteardown属性来实现的。它的工作方式如下:

def setup_test():
    global goog
    goog = Stock("GOOG")

def teardown_test():
    global goog
    goog = None

def test_price_of_a_new_stock_class_should_be_None():
    assert goog.price is None, "Price of a new stock should be None"

test_price_of_a_new_stock_class_should_be_None.setup = setup_test
test_price_of_a_new_stock_class_should_be_None.teardown = \ teardown_test

在函数式测试中,设置和清理是有限的,因为在 setup 函数和测试用例以及清理函数之间无法传递状态。这就是为什么我们必须在设置中声明 goog 变量为全局变量的原因。这是我们在测试用例和清理函数中访问它的唯一方法。

参数化测试

nose2 也支持参数化测试。也称为数据驱动测试,这些测试不过是运行相同的测试,但使用不同的数据组合。

看看我们之前写的以下三个测试:

class StockTrendTest(unittest.TestCase):
    def setUp(self):
        self.goog = Stock("GOOG")

    def given_a_series_of_prices(self, prices):
        timestamps = [datetime(2014, 2, 10), datetime(2014, 2, 11),
                      datetime(2014, 2, 12), datetime(2014, 2, 13)]
        for timestamp, price in zip(timestamps, prices):
            self.goog.update(timestamp, price)

    def test_increasing_trend_true_if_price_increase_for_3_updates(self):
        self.given_a_series_of_prices([8, 10, 12])
        self.assertTrue(self.goog.is_increasing_trend())

    def test_increasing_trend_is_false_if_price_decreases(self):
        self.given_a_series_of_prices([8, 12, 10])
        self.assertFalse(self.goog.is_increasing_trend())

    def test_increasing_trend_is_false_if_price_equal(self):
        self.given_a_series_of_prices([8, 10, 10])
        self.assertFalse(self.goog.is_increasing_trend())

通过参数化测试,我们可以写成如下形式:

from nose2.tools.params import params

def given_a_series_of_prices(stock, prices):
    timestamps = [datetime(2014, 2, 10), datetime(2014, 2, 11),
                  datetime(2014, 2, 12), datetime(2014, 2, 13)]
    for timestamp, price in zip(timestamps, prices):
        stock.update(timestamp, price)

@params(
    ([8, 10, 12], True),
    ([8, 12, 10], False),
    ([8, 10, 10], False)
)
def test_stock_trends(prices, expected_output):
    goog = Stock("GOOG")
    given_a_series_of_prices(goog, prices)
    assert goog.is_increasing_trend() == expected_output

params 装饰器允许我们指定一系列不同的输入。测试会针对每个输入运行一次。一个输入是一个元组,其中元组的每个元素都作为参数传递给测试函数。在上面的例子中,测试将首先使用 prices=[8, 10, 12], expected_output=True 运行,然后再次使用 prices=[8, 12, 10], expected_output=False 运行,依此类推。

当一个测试失败时,输出看起来如下:

======================================================================
FAIL: stock_alerter.tests.test_stock.test_stock_trends:2
[8, 12, 10], True
----------------------------------------------------------------------
Traceback (most recent call last):
 ...
 assert goog.is_increasing_trend() == expected_output
AssertionError

======================================================================

nose2 会将失败的参数编号显示为 :2,并在其下方显示传递给测试的确切数据。

参数化测试是一种很好的减少重复测试的方法,其中我们每次都执行相同的步骤序列,但数据各不相同。

生成测试

除了参数化测试之外,nose2 还支持生成测试。这与参数化测试类似。区别在于,参数化测试在编写测试时所有输入都是硬编码的,而生成测试可以在运行时创建。

以下是一个示例,以澄清:

def test_trend_with_all_consecutive_values_upto_100():
    for i in range(100):
        yield stock_trends_with_consecutive_prices, [i, i+1, i+2]

def stock_trends_with_consecutive_prices(prices):
    goog = Stock("GOOG")
    given_a_series_of_prices(goog, prices)
    assert goog.is_increasing_trend()

当我们运行上述测试时,我们看到已经运行了一百个测试。这里发生了什么?让我们更详细地看看。

与常规测试函数不同,这个函数会返回一个值,使其成为一个生成器函数。yield 语句返回要执行的功能以及传递给函数的数据。每次循环通过时,测试函数都会 yield,并且生成的函数会使用相应的参数执行。由于循环运行了一百次,因此生成了并执行了一百个测试。

当一个测试失败时,显示以下输出:

======================================================================
FAIL: stock_alerter.tests.test_stock.test_trend_with_all_consecutive_values_upto_100:100
[99, 100, 100]
----------------------------------------------------------------------
Traceback (most recent call last):
 ...
 assert goog.is_increasing_trend()
AssertionError

----------------------------------------------------------------------

就像参数化测试一样,输出显示了失败的测试编号以及执行测试时使用的确切输入。

如果我们查看我们的 Stock 类的测试,我们会看到我们创建了三个测试类:StockTestStockTrendTestStockCrossOverSignalTest。所有这三个类在 setUp 代码中都有一些重复,如下所示:

class StockTest(unittest.TestCase):
    def setUp(self):
        self.goog = Stock("GOOG")

class StockCrossOverSignalTest(unittest.TestCase):
    def setUp(self):
        self.goog = Stock("GOOG")

如果我们能在它们之间共享部分设置会怎样呢?

nose2 有另一种编写测试的方法,称为 。层允许我们以分层的方式组织我们的测试。以下是一些使用层重写的 Stock 测试的示例:

from nose2.tools import such

with such.A("Stock class") as it:

    @it.has_setup
    def setup():
        it.goog = Stock("GOOG")

    with it.having("a price method"):
        @it.has_setup
        def setup():
            it.goog.update(datetime(2014, 2, 12), price=10)

        @it.should("return the price")
        def test(case):
            assert it.goog.price == 10

        @it.should("return the latest price")
        def test(case):
            it.goog.update(datetime(2014, 2, 11), price=15)
            assert it.goog.price == 10

    with it.having("a trend method"):
        @it.should("return True if last three updates were increasing")
        def test(case):
            it.goog.update(datetime(2014, 2, 11), price=12)
            it.goog.update(datetime(2014, 2, 12), price=13)
            it.goog.update(datetime(2014, 2, 13), price=14)
            assert it.goog.is_increasing_trend()

    it.createTests(globals())

整个语法都是新的,所以让我们仔细看看。

首先,我们需要导入suchSuch是一个特定领域的语言名称,它使得使用 nose2 层编写测试变得容易。以下行为我们导入了它:

from nose2.tools import such

接下来,我们按照以下方式设置顶层:

with such.A("Stock class") as it:

一层可以包含设置和拆卸函数、测试用例和子层。这种用法使用了 Python 的上下文管理器语法来定义一层。我们使用such.A方法定义最顶层。这个方法的名字可能听起来很奇怪,但这个名字被选择是为了让英语使用者读起来自然。such.A接受一个字符串作为参数。这只是一个描述字符串,用于描述后续的测试。

such.A的输出被分配给一个变量。按照惯例,它被称为it,再次,这个名字被选择是为了让后续的使用看起来像英语句子。

创建了最顶层之后,我们接着为层创建设置函数,如下所示:

    @it.has_setup
    def setup():
        it.goog = Stock("GOOG")

函数的名称可以是任何东西,我们只需要通过使用has_setup装饰器来标记它为设置函数。这个装饰器是it对象的一个方法,因此我们写成@it.has_setup。同样,我们可以使用has_teardown装饰器来标记一个用于拆卸的函数。

setup函数中,我们可以将任何有状态的信息作为it对象的属性存储。这些可以在子层或测试用例中引用。

接下来,我们通过调用拥有方法创建一个子层,如下所示:

with it.having("a price method"):

再次,这是一个上下文管理器,所以我们使用with语句。与顶层层不同,我们不需要将其分配给任何变量。

子层随后定义自己的设置函数,如下所示:

        @it.has_setup
        def setup():
            it.goog.update(datetime(2014, 2, 12), price=10)

除了父层的设置函数外,还会调用这个setup函数。

接下来,我们创建一个测试用例,如下所示:

        @it.should("return the price")
        def test(case):
            assert it.goog.price == 10

测试用例使用should装饰器进行标记。该装饰器接受一个描述字符串,用于解释测试内容。

我们继续使用相同的语法创建另一个测试,如下所示:

        @it.should("return the latest price")
        def test(case):
            it.goog.update(datetime(2014, 2, 11), price=15)
            assert it.goog.price == 10

这样就结束了子层。回到顶层,我们创建第二个子层来包含is_increasing_trend函数的测试,如下所示:

    with it.having("a trend method"):
        @it.should("return True if last three updates were increasing")
        def test(case):
            it.goog.update(datetime(2014, 2, 11), price=12)
            it.goog.update(datetime(2014, 2, 12), price=13)
            it.goog.update(datetime(2014, 2, 13), price=14)
            assert it.goog.is_increasing_trend()

最后,我们调用createTests方法将所有这些代码转换为测试用例,如下所示:

    it.createTests(globals())

应该在顶层层的末尾调用createTests方法。它接受当前globals的单个参数。

如果没有调用createTests方法,则不会执行任何测试。

现在我们来运行测试。层实际上是一个 nose2 插件,因此我们需要使用以下命令来启用插件并运行测试:

nose2 --plugin nose2.plugins.layers

当我们这样做时,使用 Layers 编写的三个测试将与其他所有测试一起执行。

我们可以通过启用 Layer Reporter 插件来获得更友好的输出,以下命令:

nose2 --plugin nose2.plugins.layers --layer-reporter -v

现在我们得到以下输出:

A Stock class
 having a price method
 should return the price ... ok
 should return the latest price ... ok
 having a trend method
 should return True if last three updates were increasing ... ok

我们为层和测试提供的描述性字符串在这里输出。当写得好的时候,文本应该可以像常规英语句子一样阅读。

如我们所见,Layers 允许我们逻辑地组织测试,在父层和子层之间共享固定值。一个层可以有任意数量的子层,而这些子层反过来又可以包含更多的层。

让我们快速总结一下我们刚刚学到的内容:

  • such.A: 这被用作上下文管理器来创建最顶层。

  • it.has_setup: 这是一个装饰器,用于标记层的设置函数。

  • it.has_teardown: 这是一个装饰器,用于标记层的清理函数。

  • it.having: 这被用作上下文管理器来创建一个子层。

  • it.should: 这是一个装饰器,用于标记测试用例。

  • it.createTests: 这是一个方法,它将所有 Layers 代码转换为测试用例。在顶层代码的最后调用它,传入globals()

nose2 插件

在上一节中,我们看到了在运行层测试之前我们需要启用 Layers 插件。nose2 附带了一组插件,这些插件增强了或扩展了其行为。实际上,我们之前看到的参数化测试和生成测试的支持实际上都是作为 nose2 插件实现的。区别在于参数化和生成测试默认加载,所以我们不需要明确启用它们。

在本节中,我们将查看一些流行的插件。记住,还有许多其他插件我们没有在这里讨论。

Doctest 支持

如果我们没有像上一章中描述的那样将 doctests 集成到 unittest 框架中,那么我们可以配置 nose2 来自动发现并运行 doctests。

使用以下命令激活插件:

nose2 --plugin nose2.plugins.doctests --with-doctest

这将自动发现并运行 doctests,以及其他所有类型的测试。

将测试结果写入 XML 文件

nose2 支持将测试结果写入 XML 文件。许多工具可以读取此文件格式以了解测试运行的结果。例如,持续集成工具可以找出所有测试是否通过,如果没有通过,哪些测试失败了。

使用以下命令激活插件:

nose2 --plugin nose2.plugins.junitxml --junit-xml

这将在当前目录中创建一个名为nose2-junit.xml的文件。该文件将包含类似以下内容:

<testsuite errors="0" failures="1" name="nose2-junit" skips="0" tests="166" time="0.172">

  <testcase classname="stock_alerter.tests.test_action.EmailActionTest" name="test_connection_closed_after_sending_mail" time="0.000000" />

  ...

  <testcase classname="stock_alerter.tests.test_stock.having a trend method" name="test 0000: should return True if the last three updates were increasing" time="0.000000">

    <failure message="test failure">Traceback (most recent call last):
  File "...\src\stock_alerter\tests\test_stock.py", line 78, in test
    assert it.goog.is_increasing_trend()
AssertionError
    </failure>

  </testcase>
</testsuite>

根元素提供了整个测试运行的摘要,包括错误、失败和跳过的数量,测试数量以及运行所有测试的总时间。每个子元素随后总结了一个单独的测试。如果测试失败,还包括跟踪信息。

测量测试覆盖率

nose2 还支持测量测试覆盖率。我们可以使用它来识别是否有没有测试的代码行或分支,或者哪些模块的测试覆盖率较差。

在我们能够使用此插件之前,我们需要使用以下命令安装一些依赖包:

pip install nose2[coverage-plugin]

这将安装两个包——cov-corecoverage——这些包被此插件使用。

一旦安装,我们可以使用以下命令启用插件:

nose2 --with-coverage

由于此插件默认加载,我们不需要提供--plugin参数。运行上述命令将给出以下输出:

----------- coverage: platform win32, python 3.4.0-final-0 -----------
Name                                  Stmts   Miss  Cover
---------------------------------------------------------
stock_alerter\__init__                    3      3     0%
stock_alerter\action                     18      8    56%
stock_alerter\alert                      13      4    69%
stock_alerter\event                       8      4    50%
stock_alerter\legacy                     36     12    67%
stock_alerter\processor                   8      0   100%
stock_alerter\reader                     15      5    67%
stock_alerter\rule                       33     12    64%
stock_alerter\stock                      52     19    63%

上面的输出显示了每个模块中有多少条语句,有多少条没有被测试覆盖,以及覆盖率百分比。

该插件还会创建一个名为.coverage的文件,以二进制形式存储覆盖率结果。此文件可用于获取不同类型的报告。例如,我们可以使用以下命令获取 HTML 输出:

nose2 --with-coverage --coverage-report html

该命令将创建一个名为htmlcov的目录,其中包含一组文件。如果我们用浏览器打开index.html,那么我们会得到一个完全交互式的覆盖率报告。我们可以点击任何模块,并获取关于哪些行被覆盖以及哪些行没有被覆盖的详细信息,如下面的截图所示:

测量测试覆盖率

报告类型的其他选项有term用于终端输出,term-missing用于在终端上输出未覆盖的行,以及annotate,它为每个源文件创建一个带有注释的副本,说明该行是否被覆盖。

可以像以下这样组合多个选项:

nose2 --with-coverage --coverage-report html --coverage-report term

调试测试失败

另一个有用的 nose2 插件是调试器插件。此插件会在测试失败时激活 Python 调试器(pdb),允许我们调查失败的确切原因。

使用以下命令激活插件:

nose2 --plugin nose2.plugins.debugger --debugger

当测试失败时,我们会进入 pdb,可以使用所有的 pdb 命令来调查失败原因,如下所示:

F
> c:\python34\lib\unittest\case.py(787)_baseAssertEqual()
-> raise self.failureException(msg)
(Pdb) u
> c:\python34\lib\unittest\case.py(794)assertEqual()
-> assertion_func(first, second, msg=msg)
(Pdb) u
> c:\projects\tdd_with_python\src\stock_alerter\tests\test_stock.py(60)test_stock_update()
-> self.assertEqual(100, self.goog.price)
(Pdb) self.goog.price
10

nose2 配置

运行各种插件需要使用许多命令行开关。例如,如果我们想运行覆盖率、doctest 以及 XML 输出,命令如下:

nose2 --with-coverage --coverage-report html --plugin nose2.plugins.junitxml --junit-xml --plugin nose2.plugins.doctests --with-doctest

这很麻烦,如果我们想默认运行这个组合,那么反复重复参数是非常痛苦的。

为了解决这个问题,nose2 支持将所有配置放入配置文件中。然后 nose2 将从文件中读取设置,我们就不需要在命令行上传递任何内容了。

src目录中创建一个名为nose2.cfg的文件,内容如下:

[unittest]
test-file-pattern=test_*.py
test-method-prefix=test
plugins = nose2.plugins.coverage
          nose2.plugins.junitxml
          nose2.plugins.layers
exclude-plugins = nose2.plugins.doctest

[layer-reporter]
always-on = False
colors = True

[junit-xml]
always-on = True
path = nose2.xml

[coverage]
always-on = False
coverage-report = ["html", "xml"]

让我们检查这些内容。

nose2 使用正常的 INI 文件语法进行配置。通用配置放在[unittest]部分,而插件特定选项放在各自的章节下。在每个章节下,使用键值对配置选项。

我们在上面已经配置了以下内容:

  • test-file-pattern:这是在自动发现中识别测试文件时在文件名中搜索的模式。

  • test-method-prefix:这是用于识别测试用例函数和方法名称的搜索前缀。

  • 插件:这些是默认加载的插件。将每个插件放在单独的一行上,在此处引用插件模块。这相当于--plugin命令行开关。请注意,这仅加载插件,某些插件需要显式开启(例如,coverage插件)。

  • 排除插件:这些是需要关闭的任何插件。通常,这应用于默认开启的插件(例如,参数化或生成测试支持)。

然后我们配置插件。每个插件都有自己的选项集。一个共同的选项如下:

  • 始终开启:如果此插件默认开启,则设置为True。例如,当JUnit插件始终开启时,每次测试运行都会创建 XML 文件输出。否则,我们必须在命令行上使用--junit-xml开关来激活它。

nose2 还支持多个配置文件。可以使用--config开关来指定要使用的配置文件,如下所示:

nose2 --config <filename>

这样你可以使用开发选项的默认配置,并为持续集成或其他用途创建特定的配置文件。例如,你可能希望在自动化工具运行时始终开启 junitxml 和覆盖率,但在开发人员运行测试时关闭它们。

nose2 的配置文件也可以被提交到源代码控制中,这样所有开发人员都会使用相同的选项集。

摘要

在本章中,你了解了 nose2,一个强大的测试运行器和插件套件,它扩展了unittest框架。nose2 可以用作unittest测试运行器的直接替代品。它还可以用来通过有用的插件扩展unittest的功能。最后,它可以用来编写新的测试类型,如函数测试、参数化测试、生成测试和基于层的测试。nose2 还支持配置文件,因此它可以在开发人员之间保持一致性运行,并且与自动化工具很好地集成。

在下一章中,你将了解一些更高级的测试模式。

第九章:单元测试模式

在整本书中,我们探讨了 TDD 中的各种模式和反模式。在本章中,你将了解一些本书之前未讨论过的额外模式。在这个过程中,你还将了解 Python unittest模块提供的更多高级功能,例如测试加载器、测试运行器和跳过测试。

模式 - 快速测试

TDD 的一个关键目标是编写执行快速的测试。在进行 TDD 时,我们会频繁地运行测试——可能每隔几分钟就会运行一次。TDD 的习惯是在开发代码、重构、提交前和部署前多次运行测试。如果测试运行时间过长,我们就不愿意频繁运行它们,这样就违背了测试的目的。

考虑到这一点,以下是一些保持测试快速运行的技术:

  • 禁用不需要的外部服务:有些服务并非应用目的的核心,可以被禁用。例如,我们可能使用一个服务来收集用户如何使用我们应用的分析数据。我们的应用可能在每次操作时都会调用这个服务。这样的服务可以被禁用,从而使得测试运行得更快。

  • 模拟外部服务:其他外部服务,如服务器、数据库、缓存等,可能对应用的功能至关重要。外部服务需要时间来启动、关闭和通信。我们想要模拟这些服务,并让我们的测试在模拟服务上运行。

  • 使用服务的快速变体:如果我们必须使用服务,那么请确保它是快速的。例如,用一个内存数据库替换数据库,它更快,启动和关闭所需时间更少。同样,我们可以用一个记录要发送的电子邮件的内存电子邮件服务器替换对电子邮件服务器的调用,而不实际发送电子邮件。

  • 外部化配置:配置与单元测试有什么关系?简单来说:如果我们需要启用或禁用服务,或者用模拟服务替换服务,那么我们需要为常规应用和运行单元测试时设计不同的配置。这要求我们以允许我们轻松地在多个配置之间切换的方式设计应用。

  • 仅运行当前模块的测试unittest测试运行器和第三方运行器都允许我们运行测试子集——特定模块、类或单个测试的测试。这对于拥有数千个测试的大型测试套件来说是一个很好的功能,因为它允许我们只运行正在工作的模块的测试。

模式 - 运行测试子集

我们已经看到了一种简单的方法来运行测试子集,只需在命令行上指定模块或测试类,如下所示:

python -m unittest stock_alerter.tests.test_stock
python -m unittest stock_alerter.tests.test_stock.StockTest

这适用于我们想要基于模块运行子集的常见情况。如果我们想根据其他参数运行测试怎么办?也许我们想运行一组基本烟雾测试,或者我们只想运行集成测试,或者我们想在特定平台或 Python 版本上运行时跳过测试。

unittest 模块允许我们创建测试套件。测试套件是一组要运行的测试类。默认情况下,unittest 会自动发现测试并在内部创建一个包含所有匹配发现模式的测试的测试套件。然而,我们也可以手动创建不同的测试套件并运行它们。

测试套件是通过使用 unittest.TestSuite 类创建的。TestSuite 类有以下两个感兴趣的方法:

  • addTest: 此方法接受一个 TestCase 或另一个 TestSuite 并将其添加到套件中

  • addTests: 与 addTest 类似,此方法接受一个 TestCaseTestSuite 列表并将其添加到套件中

那么,我们如何使用这个函数呢?

首先,我们编写一个函数来创建套件并返回它,如下所示:

def suite():
    test_suite = unittest.TestSuite()
    test_suite.addTest(StockTest("test_stock_update"))
    return test_suite

我们可以选择套件中想要的特定测试。我们在这里向套件中添加了一个单个测试。

接下来,我们需要编写一个脚本来运行此套件,如下所示:

import unittest

from stock_alerter.tests import test_stock

if __name__ == "__main__":
    runner = unittest.TextTestRunner()
    runner.run(test_stock.suite())

在这里,我们创建了一个 TextTestRunner,它将运行测试并将套件或测试传递给它。unittest.TextTestRunner 是一个测试运行器,它接受一个测试套件并运行它,在控制台上显示测试的运行结果。

注意

unittest.TextTestRunner 是我们迄今为止一直在使用的默认测试运行器。我们可以编写自己的测试运行器。例如,我们可能会编写一个自定义测试运行器来实现 GUI 接口,或者一个将测试输出写入 XML 文件的测试运行器。

当我们运行此脚本时,我们得到以下输出:

.
------------------------
Ran 1 test in 0.000s

OK

同样,我们可以为不同的测试子集创建不同的套件——例如,一个只包含集成测试的单独套件——并根据我们的需求只运行特定的套件。

测试加载器

套件函数的一个问题是,我们必须将每个测试单独添加到套件中。如果我们有很多测试,这是一个繁琐的过程。幸运的是,我们可以通过使用 unittest.TestLoader 对象来简化这个过程,如下所示:

def suite():
    loader = unittest.TestLoader()
    test_suite = unittest.TestSuite()
    test_suite.addTest(StockTest("test_stock_update"))
    test_suite.addTest(
        loader.loadTestsFromTestCase(StockCrossOverSignalTest))
    return test_suite

在这里,加载器从 StockCrossOverSignalTest 类中提取所有测试并创建一个套件。如果我们只想返回套件,我们可以直接返回套件,或者我们可以创建一个新的套件并添加额外的测试。在上面的例子中,我们创建了一个包含 StockTest 类中的一个测试和 StockCrossOverSignalTest 类中所有测试的套件。

unittest.TestLoader 还包含一些其他用于加载测试的方法:

  • loadTestsFromModule: 此方法接受一个模块并返回该模块中所有测试的测试套件。

  • loadTestsFromName:此方法接受一个指向模块、类或函数的字符串引用,并从中提取测试。如果是一个函数,则调用该函数,并返回函数返回的测试套件。字符串引用采用点格式,这意味着我们可以传递类似stock_alerter.tests.test_stockstock_alerter.tests.test_stock.StockTest,甚至stock_alerter.tests.test_stock.suite的内容。

  • discover:此方法执行默认的自动发现过程,并将收集到的测试作为套件返回。该方法接受三个参数:起始目录、查找test模块的模式(默认test*.py)和顶级目录。

使用这些方法,我们可以仅创建我们想要的测试套件。我们可以为不同的目的创建不同的套件,并从测试脚本中执行它们。

使用 load_tests 协议

创建测试套件的一个更简单的方法是使用load_tests函数。正如我们在第七章中看到的,“使用 doctest 的可执行文档”,如果测试模块中存在load_tests函数,unittest框架会调用该函数。该函数应返回一个包含要运行的测试的TestSuite对象。当我们只想稍微修改默认的自动发现过程时,load_tests是一个更好的解决方案。

load_tests传递三个参数:用于加载测试的加载器、默认将要加载的测试套件以及为搜索指定的测试模式。

假设我们不想在当前平台是 Windows 时运行StockCrossOverSignalTest测试。我们可以编写一个如下所示的load_tests函数:

def load_tests(loader, tests, pattern):
    suite = unittest.TestSuite()
    suite.addTest(loader.loadTestsFromTestCase(StockTest))
    if not sys.platform.startswith("win"):
        suite.addTest(
            loader.loadTestsFromTestCase(StockCrossOverSignalTest))
    return suite

现在,StockCrossOverSignalTest测试将仅在非 Windows 平台上运行。当使用load_tests方法时,我们不需要编写单独的脚本来运行测试或创建测试运行器。它挂钩到自动发现过程,因此使用起来更简单。

跳过测试

在上一节中,我们使用load_tests机制在平台是 Windows 时跳过一些测试。unittest模块提供了一个更简单的使用skip装饰器来完成相同任务的方法。只需用装饰器装饰一个类或方法,测试就会跳过,如下所示:

@unittest.skip("skip this test for now")
def test_stock_update(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    assert_that(self.goog.price, equal_to(10))

装饰器接受一个参数,我们可以在其中指定跳过测试的原因。当我们运行所有测试时,我们会得到如下所示的输出:

........................................................s..
-------------------------------------------------------------
Ran 59 tests in 0.094s

OK (skipped=1)

当以详细模式运行测试时,我们会得到如下所示的输出:

test_stock_update (stock_alerter.tests.test_stock.StockTest) ... skipped 'skip this test for now'

skip装饰器无条件地跳过测试,但unittest提供了两个额外的装饰器skipIfskipUnless,允许我们指定一个条件来跳过测试。这些装饰器将布尔值作为第一个参数,将消息作为第二个参数。skipIf如果布尔值为True则跳过测试,而skipUnless如果布尔值为False则跳过测试。

以下测试将在所有平台(除了 Windows)上运行:

@unittest.skipIf(sys.platform.startswith("win"), "skip on windows")
def test_stock_price_should_give_the_latest_price(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.goog.update(datetime(2014, 2, 13), price=8.4)
    self.assertAlmostEqual(8.4, self.goog.price, delta=0.0001)

而下面的测试只会在 Windows 上运行:

@unittest.skipUnless(sys.platform.startswith("win"), "only run on windows")
def test_price_is_the_latest_even_if_updates_are_made_out_of_order(self):
    self.goog.update(datetime(2014, 2, 13), price=8)
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.assertEqual(8, self.goog.price)

skipskipIfskipUnless装饰器可以用于测试方法和测试类。当应用于类时,类中的所有测试都将被跳过。

模式 - 使用属性

nose2测试运行器有一个有用的attrib插件,允许我们在测试用例上设置属性并选择匹配特定属性的测试。

例如,以下测试设置了三个属性:

def test_stock_update(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.assertEqual(10, self.goog.price)

test_stock_update.slow = True
test_stock_update.integration = True
test_stock_update.python = ["2.6", "3.4"]

当通过以下命令运行 nose2 时,插件将被启用,并且只有设置了integration属性为True的测试将被执行:

nose2 --plugin=nose2.plugins.attrib -A "integration"

插件还可以运行所有在列表中具有特定值的测试。以下是一个命令示例:

nose2 --plugin=nose2.plugins.attrib -A "python=2.6"

上述命令将运行所有将python属性设置为2.6或包含列表中的值2.6的测试。它将选择并运行之前显示的test_stock_update测试。

插件还可以运行所有没有设置属性的测试。以下是一个命令示例:

nose2 --plugin=nose2.plugins.attrib -A "!slow"

上述命令将运行所有未标记为慢速的测试。

插件还可以接受复杂条件,因此我们可以给出以下命令:

nose2 --plugin=nose2.plugins.attrib -E "integration and '2.6' in python"

此测试运行所有具有integration属性以及python属性列表中的2.6的测试。请注意,我们使用了-E开关来指定我们正在提供一个python条件表达式。

属性插件是一个很好的方法,可以在不手动从每个可能的组合中创建测试套件的情况下运行特定的测试子集。

使用 vanilla unittests 的属性

attrib插件需要 nose2 才能工作。如果我们正在使用常规的unittest模块怎么办?unittest模块的设计允许我们仅用几行代码轻松编写一个简化版本,如下所示:

import unittest

class AttribLoader(unittest.TestLoader):
    def __init__(self, attrib):
        self.attrib = attrib

    def loadTestsFromModule(self, module, use_load_tests=False):
        return super().loadTestsFromModule(module, use_load_tests=False)

    def getTestCaseNames(self, testCaseClass):
        test_names = super().getTestCaseNames(testCaseClass)
        filtered_test_names = [test
                               for test in test_names
                               if hasattr(getattr(testCaseClass, test), self.attrib)]
        return filtered_test_names

if __name__ == "__main__":
    loader = AttribLoader("slow")
    test_suite = loader.discover(".")
    runner = unittest.TextTestRunner()
    runner.run(test_suite)

这段小代码将只运行那些在测试函数上设置了integration属性的测试。让我们更深入地看看代码。

首先,我们继承默认的unittest.TestLoader类,并创建我们自己的加载器,称为AttribLoader。记住,加载器是负责从类或模块中加载测试的类。

接下来,我们重写getTestCaseNames方法。此方法从一个类中返回一个测试用例名称列表。在这里,我们调用父方法以获取默认的测试列表,然后选择具有所需属性的测试函数。这个过滤后的列表将被返回,并且只有这些测试将被执行。

那么,为什么我们还要重写loadTestsFromModule方法呢?简单来说:加载测试的默认行为是按方法上的test前缀进行匹配,但如果存在load_tests函数,则所有操作都将委托给load_tests函数。因此,所有定义了load_tests函数的模块都将优先于我们的属性过滤方案。

当使用我们的加载器时,我们调用默认实现,但将use_load_tests参数设置为False。这意味着将不会执行任何load_tests函数,要加载的测试将由我们返回的过滤列表确定。如果我们想优先考虑load_tests(这是默认行为),那么我们只需从AttribLoader中移除此方法。

好的,现在加载器准备好了,我们修改我们的测试运行脚本以使用这个加载器,而不是默认加载器。我们通过调用discover方法来获取加载的测试套件,该方法反过来调用我们重写的getTestCaseNames方法。我们将这个套件传递给运行器并运行测试。

加载器可以很容易地修改以支持选择没有给定属性或支持更复杂条件的测试。然后我们可以添加对脚本的支持,以接受命令行上的属性并将其传递给加载器。

模式 - 预期失败

有时候,我们有一些失败的测试,但由于某种原因,我们不想立即修复它们。这可能是因为我们找到了一个错误并编写了一个失败的测试来演示该错误(这是一个非常好的做法),但我们决定稍后修复错误。现在,整个测试套件都在失败。

一方面,我们不希望套件失败,因为我们知道这个错误并想稍后修复它。另一方面,我们不想从套件中移除测试,因为它提醒我们需要修复错误。我们该怎么办?

Python 的unittest模块提供了一个解决方案:将测试标记为预期失败。我们可以通过将unittest.expectedFailure装饰器应用于测试来实现这一点。以下是一个实际应用的示例:

class AlertTest(unittest.TestCase):
    @unittest.expectedFailure
    def test_action_is_executed_when_rule_matches(self):
        goog = mock.MagicMock(spec=Stock)
        goog.updated = Event()
        goog.update.side_effect = \
            lambda date, value: goog.updated.fire(self)
        exchange = {"GOOG": goog}
        rule = mock.MagicMock(spec=PriceRule)
        rule.matches.return_value = True
        rule.depends_on.return_value = {"GOOG"}
        action = mock.MagicMock()
        alert = Alert("sample alert", rule, action)
        alert.connect(exchange)
        exchange["GOOG"].update(datetime(2014, 2, 10), 11)
        action.execute.assert_called_with("sample alerts")

当执行测试时,我们得到以下输出:

......x....................................................
------------------------------------------------------------
Ran 59 tests in 0.188s

OK (expected failures=1)

以下是其详细输出:

test_action_is_executed_when_rule_matches (stock_alerter.tests.test_alert.AlertTest) ... expected failure

模式 - 数据驱动测试

我们之前简要探讨了数据驱动测试。数据驱动测试通过允许我们编写单个测试执行流程并使用不同的数据组合运行它来减少样板测试代码的数量。

以下是一个使用我们在这本书前面提到的 nose2 参数化插件的示例:

from nose2.tools.params import params

def given_a_series_of_prices(stock, prices):
    timestamps = [datetime(2014, 2, 10), datetime(2014, 2, 11),
                  datetime(2014, 2, 12), datetime(2014, 2, 13)]
    for timestamp, price in zip(timestamps, prices):
        stock.update(timestamp, price)

@params(
    ([8, 10, 12], True),
    ([8, 12, 10], False),
    ([8, 10, 10], False)
)
def test_stock_trends(prices, expected_output):
    goog = Stock("GOOG")
    given_a_series_of_prices(goog, prices)
    assert goog.is_increasing_trend() == expected_output

运行此类测试需要使用 nose2。是否有方法使用常规的unittest模块做类似的事情?长期以来,没有不使用元类就能做到这一点的方法,但 Python 3.4 新增的一个特性使得这成为可能。

这个新特性是unittest.subTest上下文管理器。上下文管理器块内的所有代码都将被视为一个单独的测试,任何失败都将独立报告。以下是一个示例:

class StockTrendTest(unittest.TestCase):
    def given_a_series_of_prices(self, stock, prices):
        timestamps = [datetime(2014, 2, 10), datetime(2014, 2, 11),
                      datetime(2014, 2, 12), datetime(2014, 2, 13)]
        for timestamp, price in zip(timestamps, prices):
            stock.update(timestamp, price)

    def test_stock_trends(self):
        dataset = [
            ([8, 10, 12], True),
            ([8, 12, 10], False),
            ([8, 10, 10], False)
        ]
        for data in dataset:
            prices, output = data
            with self.subTest(prices=prices, output=output):
                goog = Stock("GOOG")
                self.given_a_series_of_prices(goog, prices)
                self.assertEqual(output, goog.is_increasing_trend())

在这个例子中,测试遍历不同的场景并对每个场景进行断言。整个 Arrange-Act-Assert 模式都发生在subTest上下文管理器内部。上下文管理器接受任何关键字参数作为参数,并在显示错误消息时使用这些参数。

当我们运行测试时,我们得到如下输出:

.
------------------------
Ran 1 test in 0.000s

OK

如我们所见,整个测试被视为单个测试,并且它显示测试通过了。

假设我们将测试改为使其在三个案例中的两个案例中失败,如下所示:

class StockTrendTest(unittest.TestCase):
    def given_a_series_of_prices(self, stock, prices):
        timestamps = [datetime(2014, 2, 10), datetime(2014, 2, 11),
                      datetime(2014, 2, 12), datetime(2014, 2, 13)]
        for timestamp, price in zip(timestamps, prices):
            stock.update(timestamp, price)

    def test_stock_trends(self):
        dataset = [
            ([8, 10, 12], True),
            ([8, 12, 10], True),
            ([8, 10, 10], True)
        ]
        for data in dataset:
            prices, output = data
            with self.subTest(prices=prices, output=output):
                goog = Stock("GOOG")
                self.given_a_series_of_prices(goog, prices)
                self.assertEqual(output, goog.is_increasing_trend())

然后,输出变为以下内容:

======================================================================
FAIL: test_stock_trends (stock_alerter.tests.test_stock.StockTrendTest) (output=True, prices=[8, 12, 10])
----------------------------------------------------------------------
Traceback (most recent call last):
 File "c:\Projects\tdd_with_python\src\stock_alerter\tests\test_stock.py", line 78, in test_stock_trends
 self.assertEqual(output, goog.is_increasing_trend())
AssertionError: True != False

======================================================================
FAIL: test_stock_trends (stock_alerter.tests.test_stock.StockTrendTest) (output=True, prices=[8, 10, 10])
----------------------------------------------------------------------
Traceback (most recent call last):
 File "c:\Projects\tdd_with_python\src\stock_alerter\tests\test_stock.py", line 78, in test_stock_trends
 self.assertEqual(output, goog.is_increasing_trend())
AssertionError: True != False

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=2)

如前所述输出所示,它显示只运行了一个测试,但每个失败都会单独报告。此外,当测试失败时使用的值被附加到测试名称的末尾,这使得可以很容易地看到哪个条件失败了。这里显示的参数是传递给subTest上下文管理器的参数。

模式 - 集成和系统测试

在整本书中,我们强调了单元测试不是集成测试的事实。它们有不同的目的,即验证系统在集成时是否工作。话虽如此,集成测试也很重要,不应被忽视。集成测试可以使用我们用于编写单元测试的相同unittest框架来编写。编写集成测试时需要记住的关键点如下:

  • 仍然禁用非核心服务:保持非核心服务,如分析或日志记录,禁用。这些不会影响应用程序的功能。

  • 启用所有核心服务:每个其他服务都应该处于活动状态。我们不希望模拟或伪造这些服务,因为这违背了集成测试的全部目的。

  • 使用属性标记集成测试:通过这样做,我们可以在开发期间轻松选择仅运行单元测试,同时允许在持续集成或部署之前运行集成测试。

  • 尽量减少设置和拆卸时间:例如,不要为每个测试启动和停止服务器。相反,使用模块或包级别的固定装置在所有测试中只启动和停止一次服务。在这样做的时候,我们必须小心,确保我们的测试不会在测试之间破坏服务的状态。特别是,失败的测试或测试错误不应该使服务处于不一致的状态。

模式 - 间谍

模拟允许我们用一个虚拟模拟对象替换一个对象或类。我们已经看到我们如何使模拟返回预定义的值,这样被测试的类甚至不知道它已经调用了一个模拟对象。然而,有时我们可能只想记录对对象的调用,但允许执行流程继续到真实对象并返回。这样的对象被称为间谍。间谍保留了记录调用和在之后对调用进行断言的功能,但它并不像常规模拟那样替换真实对象。

创建mock.Mock对象时的wraps参数允许我们在代码中创建间谍行为。它接受一个对象作为值,所有对模拟的调用都转发到我们传递的对象,并将返回值发送回调用者。以下是一个示例:

def test_action_doesnt_fire_if_rule_doesnt_match(self):
    goog = Stock("GOOG")
    exchange = {"GOOG": goog}
    rule = PriceRule("GOOG", lambda stock: stock.price > 10)
    rule_spy = mock.MagicMock(wraps=rule)
    action = mock.MagicMock()
    alert = Alert("sample alert", rule_spy, action)
    alert.connect(exchange)
    alert.check_rule(goog)
    rule_spy.matches.assert_called_with(exchange)
    self.assertFalse(action.execute.called)

在上面的例子中,我们为rule对象创建了一个间谍。间谍只是一个普通的模拟对象,它包装了在wraps参数中指定的真实对象。然后我们将间谍传递给警报。当alert.check_rule执行时,该方法在间谍上调用matches方法。间谍记录调用细节,然后将调用转发到真实规则对象并返回真实对象的值。然后我们可以对间谍进行断言以验证调用。

间谍通常用于我们想要避免过度模拟并使用真实对象,但又想对特定的调用进行断言的情况。它们也用于难以手动计算模拟返回值时,最好是进行实际计算并返回值。

模式 - 断言一系列调用

有时候,我们想要断言在多个对象之间发生了特定的调用序列。考虑以下测试用例:

def test_action_fires_when_rule_matches(self):
    goog = Stock("GOOG")
    exchange = {"GOOG": goog}
    rule = mock.MagicMock()
    rule.matches.return_value = True
    rule.depends_on.return_value = {"GOOG"}
    action = mock.MagicMock()
    alert = Alert("sample alert", rule, action)
    alert.connect(exchange)
    goog.update(datetime(2014, 5, 14), 11)
    rule.matches.assert_called_with(exchange)
    self.assertTrue(action.execute.called)

在这个测试中,我们正在断言调用了rule.matches方法,以及调用了action.execute方法。我们编写断言的方式并没有检查这两个调用之间的顺序。即使matches方法在execute方法之后被调用,这个测试仍然会通过。如果我们想特别检查matches方法的调用发生在execute方法调用之前,该怎么办呢?

在回答这个问题之前,让我们看看这个交互式 Python 会话。首先,我们创建一个模拟对象,如下所示:

>>> from unittest import mock
>>> obj = mock.Mock()

然后,我们得到两个作为模拟对象属性的子对象,如下所示:

>>> child_obj1 = obj.child1
>>> child_obj2 = obj.child2

默认情况下,模拟对象在访问没有配置return_value的属性时,会返回新的模拟对象。所以child_obj1child_obj2也将是模拟对象。

接下来,我们在模拟对象上调用一些方法,如下所示:

>>> child_obj1.method1()
<Mock name='mock.child1.method1()' id='56161448'>
>>> child_obj2.method1()
<Mock name='mock.child2.method1()' id='56161672'>
>>> child_obj2.method2()
<Mock name='mock.child2.method2()' id='56162008'>
>>> obj.method()
<Mock name='mock.method()' id='56162232'>

再次,没有配置return_value,所以方法调用的默认行为是返回新的模拟对象。在这个例子中,我们可以忽略这些。

现在,让我们看一下子对象的mock_calls属性。这个属性包含了对模拟对象上记录的所有调用的列表,如下所示:

>>> child_obj1.mock_calls
[call.method1()]
>>> child_obj2.mock_calls
[call.method1(), call.method2()]

模拟对象有记录的适当方法调用,正如预期的那样。现在,让我们看一下主obj模拟对象上的属性,如下所示:

>>> obj.mock_calls
[call.child1.method1(),
 call.child2.method1(),
 call.child2.method2(),
 call.method()]

现在令人惊讶的是!主模拟对象似乎不仅有自己的调用细节,还有子模拟对象的所有调用!

那么,我们如何在测试中使用这个特性来断言不同模拟对象之间调用的顺序呢?

好吧,如果我们把上面的测试写成以下这样呢:

def test_action_fires_when_rule_matches(self):
    goog = Stock("GOOG")
    exchange = {"GOOG": goog}
    main_mock = mock.MagicMock()
    rule = main_mock.rule
    rule.matches.return_value = True
    rule.depends_on.return_value = {"GOOG"}
    action = main_mock.action
    alert = Alert("sample alert", rule, action)
    alert.connect(exchange)
    goog.update(datetime(2014, 5, 14), 11)
    main_mock.assert_has_calls(
        [mock.call.rule.matches(exchange),
         mock.call.action.execute("sample alert")])

在这里,我们创建了一个主模拟对象,称为main_mock,而ruleaction模拟则是这个主模拟的子模拟。然后我们像往常一样使用这些模拟。区别在于我们在断言部分使用main_mock。因为main_mock记录了调用子模拟的顺序,所以这个断言可以检查对ruleaction模拟的调用顺序。

让我们更进一步。assert_has_calls方法只断言调用了调用,并且它们按照特定的顺序进行。该方法保证这些是唯一的调用。在第一个调用之前或最后一个调用之后,甚至在这两个调用之间,可能还有其他调用。只要我们断言的调用被调用,并且它们之间保持了顺序,断言就会通过。

为了严格匹配调用,我们可以在mock_calls属性上简单地执行assertEqual,如下所示:

def test_action_fires_when_rule_matches(self):
    goog = Stock("GOOG")
    exchange = {"GOOG": goog}
    main_mock = mock.MagicMock()
    rule = main_mock.rule
    rule.matches.return_value = True
    rule.depends_on.return_value = {"GOOG"}
    action = main_mock.action
    alert = Alert("sample alert", rule, action)
    alert.connect(exchange)
    goog.update(datetime(2014, 5, 14), 11)
    self.assertEqual([mock.call.rule.depends_on(),
                      mock.call.rule.matches(exchange),
                      mock.call.action.execute("sample alert")],
                     main_mock.mock_calls)

在上面,我们使用预期调用列表断言mock_calls。列表必须完全匹配——没有缺失的调用,没有多余的调用,没有任何不同。需要注意的一点是,我们必须列出每一个调用。有一个调用rule.depends_on,这是在alert.connect方法中完成的。我们必须指定这个调用,即使它与我们要测试的功能无关。

通常,匹配每一个调用会导致测试变得冗长,因为所有与被测试功能无关的调用也需要放入预期的输出中。这也导致测试变得脆弱,因为即使其他地方的调用略有变化,这可能会在这个特定测试中导致行为变化,也会导致测试失败。这就是为什么assert_has_calls的默认行为是只确定预期的调用是否存在,而不是检查调用是否完全匹配。在需要完全匹配的罕见情况下,我们总是可以直接在mock_calls属性上断言。

模式 - 打开函数的修补

模拟中最常见的用例之一是模拟文件访问。这实际上有点繁琐,因为open函数可以用多种方式使用。它可以作为一个普通函数使用,也可以作为一个上下文管理器使用。数据可以通过readreadlines等方法进行读取。反过来,其中一些函数返回可以迭代的迭代器。为了在测试中使用它们,必须逐一模拟所有这些,这很痛苦。

幸运的是,模拟库提供了一个极其有用的mock_open函数,它可以返回一个处理所有这些情况的模拟。让我们看看我们如何使用这个函数。

下面的代码是FileReader的代码:

class FileReader:
    """Reads a series of stock updates from a file"""
    def __init__(self, filename):
        self.filename = filename

    def get_updates(self):
        """Returns the next update everytime the method is called"""

        with open(self.filename, "r") as fp:
            for line in fp:
                symbol, time, price = line.split(",")
                yield (symbol, datetime.strptime(time, "%Y-%m-%dT%H:%M:%S.%f"), int(price))

这个类从文件中读取股票更新,并逐个返回每个更新。这个方法是一个生成器,并使用yield关键字逐个返回更新。

注意

关于生成器的一个快速入门

生成器是使用yield语句而不是return语句来返回值的函数。每次执行生成器时,执行不会从函数的开始处开始,而是从上一个yield语句继续运行。在上面的例子中,当生成器被执行时,它会解析文件的第一个行,然后返回值。下一次执行时,它会再次通过循环继续运行,返回第二个值,然后是第三个值,依此类推,直到循环结束。每次执行生成器返回一个股票更新。有关生成器的更多信息,请查看 Python 文档或在线文章。这样一篇文章可以在www.jeffknupp.com/blog/2013/04/07/improve-your-python-yield-and-generators-explained/找到。

为了测试get_update方法,我们需要创建不同类型的文件数据,并验证该方法是否正确读取它们并返回预期的值。为了做到这一点,我们将模拟打开函数。以下是一个这样的测试:

class FileReaderTest(unittest.TestCase):
    @mock.patch("builtins.open",
                mock.mock_open(read_data="""\
                GOOG,2014-02-11T14:10:22.13,10"""))
    def test_FileReader_returns_the_file_contents(self):
        reader = FileReader("stocks.txt")
        updater = reader.get_updates()
        update = next(updater)
        self.assertEqual(("GOOG",
                          datetime(2014, 2, 11, 14, 10, 22, 130000),
                          10), update)

在上述测试中,我们是从修补builtins.open函数开始的。patch装饰器可以接受第二个参数,其中我们可以指定修补后要使用的模拟对象。我们调用mock.mock_open函数来创建一个适当的模拟对象,并将其传递给patch装饰器。

mock_open函数接受一个read_data参数,其中我们可以指定当模拟文件被读取时应返回什么数据。我们使用此参数来指定我们想要测试的文件数据。

测试的其余部分相当简单。需要注意的是以下一行:

updater = reader.get_updates()

由于get_updates是一个生成器函数,对get_updates方法的调用实际上并不返回股票更新,而是返回生成器对象。这个生成器对象存储在updater变量中。我们使用内置的next函数从生成器中获取股票更新,并断言它符合预期。

模式 - 使用可变参数进行模拟

一个可能会咬我们的是当模拟对象的参数是可变的时候。看看下面的例子:

>>> from unittest import mock
>>> param = ["abc"]
>>> obj = mock.Mock()
>>> _ = obj(param)
>>> param[0] = "123"

>>> obj.assert_called_with(["abc"])
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "C:\Python34\lib\unittest\mock.py", line 760, in assert_called_with
 raise AssertionError(_error_message()) from cause
AssertionError: Expected call: mock(['abc'])
Actual call: mock(['123'])

哇!那里发生了什么?错误信息如下:

AssertionError: Expected call: mock(['abc'])
Actual call: mock(['123'])

实际调用是mock(['123'])?但我们调用模拟的方式如下:

>>> param = ["abc"]
>>> obj = mock.Mock()
>>> _ = obj(param)

很明显,我们是用["abc"]调用的它。那么为什么这个会失败?

答案是模拟对象只存储了对调用参数的引用。因此,当执行param[0] = "123"这一行时,它影响了存储在模拟中的调用参数的值。在断言中,它查看保存的调用参数,并看到调用使用了数据["123"],所以断言失败。

显然的问题是:为什么模拟存储了参数的引用?为什么它不复制参数,这样如果稍后传递给参数的对象被更改,存储的副本就不会改变?答案是,复制创建了一个新对象,所以所有在参数列表中比较对象身份的断言都会失败。

那我们现在该怎么做?如何让这个测试工作起来?

简单:我们只是从MockMagicMock继承,并更改行为以复制参数,如下所示:

>>> from copy import deepcopy
>>>
>>> class CopyingMock(mock.MagicMock):
...     def __call__(self, *args, **kwargs):
...         args = deepcopy(args)
...         kwargs = deepcopy(kwargs)
...         return super().__call__(*args, **kwargs)

这个模拟只是复制了参数,然后调用默认行为,传入复制的内容。

断言现在通过了,如下所示:

>>> param = ["abc"]
>>> obj = CopyingMock()
>>> _ = obj(param)
>>> param[0] = "123"
>>> obj.assert_called_with(["abc"])

请记住,当我们使用CopyingMock时,我们不能使用任何对象身份比较作为参数,因为它们现在会失败,如下所示:

>>> class MyObj:
...     pass
...
>>> param = MyObj()
>>> obj = CopyingMock()
>>> _ = obj(param)

>>> obj.assert_called_with(param)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "C:\Python34\lib\unittest\mock.py", line 760, in assert_called_with
 raise AssertionError(_error_message()) from cause
AssertionError: Expected call: mock(<__main__.MyObj object at 0x00000000026BAB70>)
Actual call: mock(<__main__.MyObj object at 0x00000000026A8E10>)

摘要

在本章中,你研究了单元测试的一些其他模式。你研究了如何加快测试速度以及如何运行特定的测试子集。你研究了运行测试子集的各种模式,包括创建自己的测试套件和使用load_tests协议。你看到了如何使用 nose2 attrib 插件根据测试属性运行测试子集,以及如何使用默认单元测试运行器实现该功能。然后我们检查了跳过测试和标记测试为预期失败的功能。最后,你研究了如何编写数据驱动测试。

接下来,我们转向了一些模拟模式,首先是实现间谍功能的方法。你也研究了在多个模拟之间验证模拟调用序列的问题。然后你研究了mock_open函数,以帮助我们轻松模拟文件系统访问,在这个过程中你瞥了一眼如何与生成器函数一起工作。最后,你研究了在参数可变时使用模拟的问题。

下一章是这本书的最后一章,你将了解我们可以在我们的 TDD 实践中使用的其他工具。

第十章。提高测试驱动开发的工具

到目前为止,我们主要关注的是如何编写和运行测试。在本章中,我们将把注意力转向将测试与更广泛的开发生态集成。将测试集成到开发环境中很重要,因为它允许我们设置一个自动化的流程,通过该流程定期执行测试。我们还将探讨其他可以提高我们进行 TDD 方式效率的工具——从其他测试运行器到使断言更容易的库。

TDD 工具

在本书的早期,我们探讨了 nose2 测试运行器。Python 有其他流行的第三方测试运行器。Python 还有一系列库,可以使断言更加灵活和可读。这些库可以与 unittest 兼容的测试以及第三方测试运行器支持的函数式测试一起使用。让我们看看这些 TDD 工具中的一些。

py.test

与 nose2 一样,py.test 也是另一个流行的第三方测试运行器。py.test 支持许多功能,如下所示:

  • 将测试编写为普通函数。

  • 使用 Python 的 assert 语句进行断言。

  • 跳过测试或标记测试为预期失败的能力。

  • 支持设置和清理的固定值。

  • 可扩展的插件框架,提供插件以执行流行的功能,例如 XML 输出、覆盖率报告以及在多个处理器或核心上并行运行测试。

  • 使用属性标记测试。

  • 集成流行的工具。

py.test 最独特的特点之一是 funcargs。看看以下代码:

import pytest

@pytest.fixture
def goog():
    return Stock("GOOG")

def test_stock_update(goog):
    assert goog.price is None

在此代码中,test_stock_update 函数接受一个名为 goog 的参数。此外,我们还有一个名为 goog 的函数,该函数带有 pytest.fixture 装饰器。PyTest 将匹配这两个,调用适当的固定值,并将返回值作为参数传递给测试。

这解决了以下两个问题:

  • 它使我们能够在不使用全局变量的情况下,将固定值传递给函数式测试用例。

  • 我们可以创建许多小的固定值,而不是编写一个大的固定值,测试用例只使用它们需要的固定值。这使得阅读测试用例更容易,因为我们不需要查看一个大的设置,其中包含为不同测试准备的不同设置行。

上述示例只是对 funcargs 的表面了解。py.test 支持许多其他使用场景。绝对要检查这个流行的测试运行器。

py.test 与 nose2 的比较

在 nose2 和 py.test 之间没有太多选择。nose2 在编写使用层来编写测试方面具有独特功能,而 py.test 在 funcargs 方面具有独特功能。除此之外,两者都支持运行unittest测试用例,两者都有强大的插件框架,并且都可以与本章中讨论的所有工具集成。两者之间的选择实际上取决于个人对层与 funcargs 之间的选择,或者是否支持我们真正想要的特定插件。绝对建议查看 py.test 的主页pytest.org/latest/

尝试

Trial 是一个单元测试运行器,最初是为测试 Python 的 Twisted 框架而构建的。Trial 支持运行使用unittest模块编写的纯单元测试,以及专门针对基于网络编程的应用程序(如客户端、服务器等)的高级功能。其中最重要的是对异步编程模型的支持,其中方法可能立即返回,但实际的返回值是在稍后接收的。这通常使用一个称为Deferred的概念来完成。由于这是一个深奥且专业的话题,我们不会在本书中进行详细讨论。只需记住,如果你在进行任何网络编程、基于事件系统或异步编程的工作,那么你应该查看 Trial 的主页twistedmatrix.com/trac/wiki/TwistedTrial

当然

Sure 是一个 Python 库,旨在帮助编写易于阅读的断言。它是 should.js JavaScript 库的 Python 版本。

使用 Sure,我们可以进行以下测试:

def test_stock_update(self):
    """An update should set the price on the stock object

    We will be  using the `datetime` module for the timestamp
    """
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.assertEqual(10, self.goog.price)

然后,将其重写为以下形式:

def test_stock_update(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.goog.price.should.equal(10)

注意

注意到断言已被替换为类似常规英语的语句。Sure 向所有对象添加了大量属性,使我们能够编写这样的断言。

以下是如何在 Sure 中查看我们的浮点测试:

def test_stock_price_should_give_the_latest_price(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.goog.update(datetime(2014, 2, 13), price=8.4)
    self.goog.price.should.equal(8.4, epsilon=0.0001)

然后,以下是如何检查预期异常的方法:

def test_negative_price_should_throw_ValueError(self):
    self.goog.update.when.called_with(datetime(2014, 2, 13), -1).\
        should.throw(ValueError)

Sure 还支持使用 Python 的assert语句如下:

def test_stock_update(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    assert self.goog.price.should.equal(10)

Sure 支持大量如上所述的表达式,以表达多种断言条件。

Sure 使用一些猴子补丁来将这些属性添加到所有对象中。猴子补丁是在执行import sure语句后完成的。因此,请务必仅在单元测试文件上使用 Sure,而不要在任何生产文件中使用。

可以通过在运行测试之前将SURE_DISABLE_NEW_SYNTAX环境变量设置为 true 来禁用猴子补丁。当猴子补丁被禁用时,Sure 支持使用 expect 函数的以下替代语法:

def test_stock_price_should_give_the_latest_price(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.goog.update(datetime(2014, 2, 13), price=8.4)
    expect(self.goog.price).should.equal(8.4, epsilon=0.0001)

所有可用方法和语法的完整详情可在 Sure 主页falcao.it/sure/intro.html找到。

由于断言是普通函数,因此我们可以在编写 nose2 和 py.test 支持的函数式测试时使用此库。

PyHamcrest

PyHamcrest 是 Java Hamcrest 库的 Python 端口。这是另一个库,使我们能够编写更干净、更灵活的断言。

PyHamcrest 定义了自己的assert_that函数和多个匹配器,如equal_to。使用它们,我们可以编写如下测试:

def test_stock_update(self):
    self.goog.update(datetime(2014, 2, 12), price=10)
    assert_that(self.goog.price, equal_to(10))

PyHamcrest 还有一个通过从BaseMatcher类继承来编写自定义匹配器的系统。以下是一个自定义匹配器,它检查股票是否具有返回特定值的交叉信号:

class StockCrossoverMatcher(BaseMatcher):
    signal_names = {
        StockSignal.buy: "buy",
        StockSignal.sell: "sell",
        StockSignal.neutral: "neutral"
    }

    def __init__(self, signal, date_to_check):
        self.signal = signal
        self.date_to_check = date_to_check

    def _matches(self, item):
        return self.signal == \
            item.get_crossover_signal(self.date_to_check)

    def describe_to(self, description):
        signal_name = self.signal_names[self.signal]
        return description.append_text(
                "stock crossover signal is {} ".format(signal_name))

该类定义了两个方法:_matchesdescribe_to

_matches方法接受assert_that函数调用的第一个参数,并返回它是否匹配给定的条件。在这种情况下,我们调用get_crossover_signal方法并检查它是否匹配我们期望的信号。

describe_to方法返回一个文本描述,用于在断言失败时显示的消息。

我们还定义了一个便利函数is_buy_on,它返回一个匹配器来匹配StockSignal.buy信号,如下所示:

def is_buy_on(date_to_check):
    return StockCrossoverMatcher(StockSignal.buy, date_to_check)

使用这种方法,我们可以编写如下测试:

def test_with_upward_crossover_returns_buy(self):
    self.given_a_series_of_prices([
        29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 46])
    assert_that(self.goog, is_buy_on(datetime(2014, 2, 13)))

与 Sure 一样,PyHamcrest 断言是纯函数,适用于 nose2 和 py.test 支持的功能测试风格。您可以在 PyHamcrest 的主页github.com/hamcrest/PyHamcrest上查看。

与构建工具集成

执行测试用例只需一行代码。那么,为什么我们想要与构建工具集成呢?构建工具支持先决条件,因此通过集成此类工具,我们可以确保在执行关键任务之前运行测试。一个例子是在将代码部署到生产之前运行所有测试。

Paver

Paver 是一个基于 Python 的流行构建工具。它围绕任务的概念构建。任务是一系列执行特定操作的命令,例如构建应用程序或运行单元测试。任务使用常规 Python 编写,并放置在项目根目录下的名为pavement.py的文件中。

我们想要创建一个运行我们的单元测试的任务。以下是在 Paver 中如何做到这一点:

import subprocess
from paver.easy import task, consume_args, options, needs

@task
@consume_args
def test():
    args = []
    if hasattr(options, "args"):
        args = options.args
    p = subprocess.Popen(["python", "-m", "unittest"] + args)
    p.wait()
    return p.returncode

上述任务简单地运行一个命令来执行unittest模块。我们使用@consume_args装饰器,它告诉 Paver 接受所有命令行参数并将它们传递给此任务。

要运行此 Paver 任务,我们只需在命令行上执行以下操作:

paver test -t . -s stock_alerter

如果我们使用 nose2,则可以修改任务如下:

import subprocess
from paver.easy import task, consume_args, options, needs

@task
@consume_args
def test():
    args = []
    if hasattr(options, "args"):
        args = options.args
    p = subprocess.Popen(["nose2"] + args)
    p.wait()
    return p.returncode

一旦我们有任务,我们就可以在其他任务中使用它,如下所示:

@needs("test")
def deploy():
    # put the deployment commands here
    pass

每次执行deploy任务时,都会运行test任务。只有当测试通过时,才会进行代码的部署。

与打包工具集成

打包指的是 Python 软件包通常如何分发给用户。除非我们正在编写专有商业软件,否则我们希望将单元测试与代码一起分发,并允许最终用户运行测试以验证一切是否正常工作。

打包工具一直是 Python 生态系统中最令人困惑的部分之一。曾经有多个不同的框架,在不同的时间点,被认为是“正确”做事的方式。当这本书正在编写时,setuptools 是推荐的打包 Python 模块的方式,因此让我们首先看看它。

Setuptools

Setuptools 支持一个测试命令来运行测试套件。我们只需要配置它。我们通过在setup.py中指定test_suite参数来完成此操作,如下所示:

from setuptools import setup, find_packages

setup(
    name="StockAlerter",
    version="0.1",
    packages=find_packages(),
    test_suite="stock_alerter.tests",
)

当我们这样做时,setuptools 将使用以下命令获取并运行所有测试:

python setup.py test

上面的配置只会运行与unittest兼容的测试。我们无法运行任何 nose2 测试,也无法使用 nose2 插件。幸运的是,nose2 也支持与 setuptools 集成。nose2.collector.collector函数返回一个兼容的测试套件,setuptools 可以运行。以下测试套件执行了 nose2 找到的所有测试:

from setuptools import setup, find_packages

setup(
    name="StockAlerter",
    version="0.1",
    packages=find_packages(),
    tests_require=["nose2"],
    test_suite="nose2.collector.collector",

tests_require参数可以设置为运行测试所需的包。我们将nose2放在这里,这样即使最终用户没有安装 nose2,setuptools 也会在我们运行测试之前为我们安装它。如果我们使用任何第三方 nose2 插件,我们也可以将它们添加到列表中。

以这种方式运行测试时,我们无法传递任何参数。所有配置都需要在nose2.cfg中完成。如果我们有一些只想与setuptools测试命令一起使用的特殊设置,我们可以将这些设置放在一个特殊的setup.cfg文件中。此文件中的设置仅在通过 setuptools 运行 nose 测试时使用。

要将 py.test 与 setuptools 集成,我们需要使用以下distutils集成中使用的cmdclass技术。

Distutils

Python 自带了一个名为distutils的打包系统。虽然 setuptools 是首选方式,但我们有时可能想坚持使用 distutils,因为它包含在标准库中。

Distutils 支持向 setup.py 添加自定义命令。我们将使用该功能添加一个将运行我们的测试的命令。以下是如何实现它的样子:

import subprocess
from distutils.core import setup, Command

class TestCommand(Command):

    user_options = []

    def initialize_options(self):
        pass

    def finalize_options(self):
        pass

    def run(self):
        p = subprocess.Popen(["python", "-m", "unittest"])
        p.wait()
        raise SystemExit(p.returncode)

setup(
    name="StockAlerter",
    version="0.1",
    cmdclass={
        "test": TestCommand
    }
)

cmdclass选项允许我们传递一个包含命令名称映射到命令类的字典。我们配置测试命令并将其映射到我们的TestCommand类。

TestCommand类继承自 distutil 的Command类。Command类是一个抽象基类;子类需要创建user_options列表以及实现三个方法:initialize_optionsfinalize_optionsrun。我们不需要在前两个方法中做任何事情,所以我们将它们保持为空。

我们需要的唯一方法是run方法。当命令要被执行时,distutils 会调用此方法,我们的实现简单地运行 shell 命令并返回适当的退出值。

同样的技术也可以用来运行 nose2 测试或 py.test 测试。

与持续集成工具集成

持续集成工具允许我们在每次提交时运行测试套件,以验证我们应用程序的完整性。我们可以配置它们,如果任何测试失败,或者测试覆盖率太低,则发出警报。

Jenkins

Jenkins 是一个流行的基于 Java 的持续集成系统。与 Jenkins 集成需要 nose2 运行器,因为我们需要以 XML 格式获取输出。

我们需要做的第一件事是配置 Jenkins 在构建过程中运行单元测试。为此,我们在构建中添加一个 shell 步骤并输入运行测试的命令。我们需要启用 JUnit XML 插件并获取 XML 格式的覆盖率,如下面的截图所示:

Jenkins

我们接下来需要告诉 Jenkins 在哪里可以找到单元测试结果。选择发布 JUnit 测试结果报告复选框,并输入 nose2 单元测试 XML 文件的位置,如下面的截图所示:

Jenkins

启用发布 Cobertura 覆盖率报告并选择覆盖率 XML 输出文件的位置,如下面的截图所示。该插件还允许我们设置行覆盖率的警报限制。如果覆盖率低于此处指定的阈值,这将导致构建失败。

Jenkins

一旦完成配置,Jenkins 将在每次构建时运行测试,并为我们提供一个漂亮的单元测试趋势报告以及覆盖率统计信息,如下面的截图所示:

Jenkins

我们还可以深入了解 Jenkins 中特定套件或测试的详细信息,如下面的截图所示:

Jenkins

Travis CI

Travis CI 是新兴的热门工具,在 Python 社区中非常受欢迎,特别是在开源软件包方面,如下面的截图所示:

Travis CI

作为一项托管服务,它不需要任何安装。配置 Travis 以运行我们的单元测试非常简单。我们只需要将运行测试的命令添加到.travis.yml配置文件的script部分,如下所示:

script:
   - python -m unittest

or if we are using nose2:

script:
   - nose2

就这样。现在 Travis 将在每次提交时执行命令,并通知我们是否由于任何原因测试失败。

其他工具

tox

tox 是一个用于维护 Python 包跨多个 Python 版本的框架。例如,我们可以轻松地测试 Python 2.6 和 Python 3.4 是否一切正常。它是通过为每个版本创建虚拟环境并在该环境中运行单元测试来工作的。

注意

tox 使用 virtualenv 工具创建虚拟环境。这个工具包含在 Python 3.4 的标准库中,并且可以从 PyPi 为旧版本的 Python 安装。我们在这本书中不介绍这个工具,但如果您还没有使用它,那么请务必查看一下。

一个典型的tox配置文件如下所示:

[tox]
envlist = py33,py34

[testenv:py34]
deps = nose2
       sure
       pyhamcrest
commands = nose2

[testenv:py33]
deps = enum34
       sure
       pyhamcrest
commands = python -m unittest

配置包括要测试的 Python 版本列表。每个环境都可以安装我们运行测试所需的依赖项,以及运行测试所需的命令。这个命令可以是普通的 unittest 命令,或者像 nose2 或 py.test 这样的第三方运行器。

当 tox 执行时,它会为每个 Python 版本创建一个虚拟环境,安装所需的依赖项,并在该环境中运行我们的测试。tox 可以与持续集成系统集成,以确保每个提交的兼容性。

Sphinx

Sphinx 是一个常用的文档框架,通常与 Python 项目一起使用。Sphinx 支持在文档中嵌入代码示例片段。Sphinx 还有一个 sphinx.ext.doctest 插件,可以提取这些代码示例作为 doctests 并运行它们,以确保文档不会中断。

sphinx.ext.doctest 插件支持设置和清理的 doctest 固定值,以及 doctest 选项。当我们的应用程序需要完整的文档系统时,带有 doctest 插件的 Sphinx 是一个不错的选择。

IDE 集成

我们还没有讨论 IDE 集成。这是因为大多数流行的 Python IDE 都内置了对在 IDE 内运行单元测试的支持。这在当今几乎是一个基本功能。此外,还有适用于流行的文本编辑器(如 vim、emacs 和 Sublime Text 3)的插件。我们不会涵盖每一个,因为它们太多了。只需进行一次快速的在线搜索,就可以找到我们需要的配置或插件,以便在我们的首选 IDE 或文本编辑器中运行测试。

摘要

在本章中,你了解了一些流行的第三方工具,帮助我们改进我们的 TDD 实践。其中一些工具,如 py.testtrial,是具有独特功能的测试运行器。其他如 surepyhamcrest 的工具是库,帮助我们编写更干净的测试。你了解了如何将我们的单元测试集成到更广泛的开发生成过程中:从将它们放入构建环境,与持续集成工具集成,到在打包我们的代码时启用 test 命令。然后我们探讨了如何针对多个 Python 版本维护一个包,以及如何将测试集成到 Sphinx 中,以确保我们的文档不会中断。

附录 A. 练习答案

本附录包含本书中提出的练习的答案。请记住,对于这些练习中的任何一个,都没有唯一的正确答案。有许多可能的解决方案,每个都有其自身的优缺点。在可能的情况下,我已经提到了我选择某种路径的原因,这样你就可以看到我的推理,并比较你提出的解决方案的优缺点。

红绿重构 – TDD 循环

这个练习要求我们添加对顺序不正确的更新的支持,即,较新的更新后面跟着较旧的更新。我们需要使用时间戳来确定哪个更新较新,哪个更新较旧。

以下是对此要求的测试用例:

def test_price_is_the_latest_even_if_updates_are_made_out_of_order(self):
    self.goog.update(datetime(2014, 2, 13), price=8)
    self.goog.update(datetime(2014, 2, 12), price=10)
    self.assertEqual(8, self.goog.price)

在上面的测试中,我们首先给出了 2 月 13 日的更新,然后是 2 月 12 日的更新。然后我们断言价格属性返回最新的价格(对于 2 月 13 日)。当然,测试失败了。

为了使这个测试通过,我们不能简单地将最新的更新添加到 price_history 列表的末尾。我们需要检查时间戳并相应地将其插入列表中,保持按时间戳排序。

Python 标准库中提供的 bisect 模块包含 insort_left 函数,该函数可以将元素插入到有序列表中。我们可以如下使用此函数(记住在文件顶部导入 bisect):

def update(self, timestamp, price):
    if price < 0:
        raise ValueError("price should not be negative")
    bisect.insort_left(self.price_history, (timestamp, price))

为了有一个有序的列表,price_history 列表需要保持一个元组的列表,其中时间戳作为第一个元素。这将使列表按时间戳排序。当我们进行这个更改时,它破坏了其他期望列表只包含价格的方法。我们需要按照以下方式修改它们:

@property
def price(self):
    return self.price_history[-1][1] \
        if self.price_history else None

def is_increasing_trend(self):
    return self.price_history[-3][1] < \
        self.price_history[-2][1] < self.price_history[-1][1]

经过上述更改,我们所有的现有测试以及新的测试都开始通过。

现在我们有了通过测试,我们可以看看重构代码以使其更容易阅读。由于 price_history 列表现在包含元组,我们必须通过元组索引来引用价格元素,导致语句列表 price_history[-1][1],这并不太清晰。我们可以通过使用允许我们为元组值分配名称的命名元组来使这更清晰。我们的重构后的 Stock 类现在看起来如下:

PriceEvent = collections.namedtuple("PriceEvent", ["timestamp", "price"])

class Stock:
    def __init__(self, symbol):
        self.symbol = symbol
        self.price_history = []

    @property
    def price(self):
        return self.price_history[-1].price \
            if self.price_history else None

    def update(self, timestamp, price):
        if price < 0:
            raise ValueError("price should not be negative")
        bisect.insort_left(self.price_history, PriceEvent(timestamp, price))

    def is_increasing_trend(self):
        return self.price_history[-3].price < \
            self.price_history[-2].price < \
                self.price_history[-1].price

在更改后,我们运行测试以确保一切仍然正常工作。

代码异味和重构

这个练习要求我们重构 Stock 类,并将所有与移动平均相关的计算提取到一个新的类中。

以下是我们开始时的代码:

def get_crossover_signal(self, on_date):
    NUM_DAYS = self.LONG_TERM_TIMESPAN + 1
    closing_price_list = \
        self.history.get_closing_price_list(on_date, NUM_DAYS)

    if len(closing_price_list) < NUM_DAYS:
        return StockSignal.neutral

    long_term_series = \
        closing_price_list[-self.LONG_TERM_TIMESPAN:]
    prev_long_term_series = \
        closing_price_list[-self.LONG_TERM_TIMESPAN-1:-1]
    short_term_series = \
        closing_price_list[-self.SHORT_TERM_TIMESPAN:]
    prev_short_term_series = \
        closing_price_list[-self.SHORT_TERM_TIMESPAN-1:-1]

    long_term_ma = sum([update.value
                        for update in long_term_series])\
                    /self.LONG_TERM_TIMESPAN
    prev_long_term_ma = sum([update.value
                             for update in prev_long_term_series])\
                         /self.LONG_TERM_TIMESPAN
    short_term_ma = sum([update.value
                         for update in short_term_series])\
                    /self.SHORT_TERM_TIMESPAN
    prev_short_term_ma = sum([update.value
                              for update in prev_short_term_series])\
                         /self.SHORT_TERM_TIMESPAN

    if self._is_crossover_below_to_above(prev_short_term_ma,
                                         prev_long_term_ma,
                                         short_term_ma,
                                         long_term_ma):
                return StockSignal.buy

    if self._is_crossover_below_to_above(prev_long_term_ma,
                                         prev_short_term_ma,
                                         long_term_ma,
                                         short_term_ma):
                return StockSignal.sell

    return StockSignal.neutral

如我们所见,有许多与识别移动平均窗口和计算移动平均值相关的计算。这些计算真的值得放在它们自己的类中。

首先,我们创建一个空的 MovingAverage 类,如下所示:

class MovingAverage:
    pass

现在我们需要做出设计决策,决定我们希望这个类如何被使用。让我们决定这个类应该接受一个基础的时间序列,并且应该能够根据该时间序列在任何一点计算移动平均。根据这个设计,这个类需要接受时间序列和移动平均的持续时间作为参数,如下所示:

def __init__(self, series, timespan):
    self.series = series
    self.timespan = timespan

我们现在可以将移动平均计算提取到这个类中,如下所示:

class MovingAverage:
    def __init__(self, series, timespan):
        self.series = series
        self.timespan = timespan

    def value_on(self, end_date):
        moving_average_range = self.series.get_closing_price_list(
                                   end_date, self.timespan)
        if len(moving_average_range) < self.timespan:
            raise NotEnoughDataException("Not enough data")
        price_list = [item.value for item in moving_average_range]
        return sum(price_list)/len(price_list)

这是从Stock.get_signal_crossover中相同的移动平均计算代码。唯一值得注意的是,如果数据不足以进行计算,则会引发异常。让我们在timeseries.py文件中定义此异常,如下所示:

class NotEnoughDataException(Exception):
    pass

现在,我们可以在Stock.get_signal_crossover中使用此方法,如下所示:

def get_crossover_signal(self, on_date):
    prev_date = on_date - timedelta(1)
    long_term_ma = \
        MovingAverage(self.history, self.LONG_TERM_TIMESPAN)
    short_term_ma = \
        MovingAverage(self.history, self.SHORT_TERM_TIMESPAN)

    try:
        long_term_ma_value = long_term_ma.value_on(on_date)
        prev_long_term_ma_value = long_term_ma.value_on(prev_date)
        short_term_ma_value = short_term_ma.value_on(on_date)
        prev_short_term_ma_value = short_term_ma.value_on(prev_date)
    except NotEnoughDataException:
        return StockSignal.neutral

    if self._is_crossover_below_to_above(prev_short_term_ma_value,
                                         prev_long_term_ma_value,
                                         short_term_ma_value,
                                         long_term_ma_value):
                return StockSignal.buy

    if self._is_crossover_below_to_above(prev_long_term_ma_value,
                                         prev_short_term_ma_value,
                                         long_term_ma_value,
                                         short_term_ma_value):
                return StockSignal.sell

    return StockSignal.neutral

运行测试,所有 21 个测试应该通过。

一旦我们将计算提取到类中,我们会发现,在第三章的“用临时变量替换计算”部分中创建的临时变量实际上并不是必需的。没有它们,代码同样具有自解释性,因此我们现在可以去掉它们,如下所示:

def get_crossover_signal(self, on_date):
    prev_date = on_date - timedelta(1)
    long_term_ma = \
        MovingAverage(self.history, self.LONG_TERM_TIMESPAN)
    short_term_ma = \
        MovingAverage(self.history, self.SHORT_TERM_TIMESPAN)

    try:
        if self._is_crossover_below_to_above(
                short_term_ma.value_on(prev_date),
                long_term_ma.value_on(prev_date),
                short_term_ma.value_on(on_date),
                long_term_ma.value_on(on_date)):
            return StockSignal.buy

        if self._is_crossover_below_to_above(
                long_term_ma.value_on(prev_date),
                short_term_ma.value_on(prev_date),
                long_term_ma.value_on(on_date),
                short_term_ma.value_on(on_date)):
            return StockSignal.sell
    except NotEnoughDataException:
        return StockSignal.neutral

    return StockSignal.neutral

最后的清理:现在我们有了移动平均类,我们可以将_is_crossover_below_to_above方法的参数替换为移动平均类,而不是单个值。现在该方法如下所示:

def _is_crossover_below_to_above(self, on_date, ma, reference_ma):
    prev_date = on_date - timedelta(1)
    return (ma.value_on(prev_date)
                < reference_ma.value_on(prev_date)
            and ma.value_on(on_date)
                > reference_ma.value_on(on_date))

我们可以将get_crossover_signal方法修改为使用以下新参数调用:

def get_crossover_signal(self, on_date):
    long_term_ma = \
        MovingAverage(self.history, self.LONG_TERM_TIMESPAN)
    short_term_ma = \
        MovingAverage(self.history, self.SHORT_TERM_TIMESPAN)

    try:
        if self._is_crossover_below_to_above(
                on_date,
                short_term_ma,
                long_term_ma):
            return StockSignal.buy

        if self._is_crossover_below_to_above(
                on_date,
                long_term_ma,
                short_term_ma):
            return StockSignal.sell
    except NotEnoughDataException:
        return StockSignal.neutral

    return StockSignal.neutral

这样,我们的提取类重构就完成了。

get_crossover_signal类现在非常易于阅读和理解。

注意到MovingAverage类的设计是如何建立在之前提取的TimeSeries类之上的。当我们重构代码并提取类时,我们经常发现许多类在其他上下文中被重复使用。这是拥有具有单一职责的小类的好处。

将重构到单独的类还允许我们删除之前创建的临时变量,并使交叉条件参数变得更加简单。再次强调,这些都是拥有具有单一职责的小类的副作用。

附录 B.处理旧版 Python 版本

本书是为 Python 3.4 编写的。Python 2.x 标准库中包含的unittest版本是一个较旧的版本,它不支持本书中讨论的所有功能。此外,mock库直到 Python 3.3 才开始成为标准库的一部分。

幸运的是,Python 新版本中所有现有的功能都已经通过unittest2库回滚。我们可以使用以下命令从 PyPi 安装此版本:

pip install unittest2

安装完成后,我们必须在所有如下引用中使用unittest2库:

import unittest2

class StockTest(unittest2.TestCase):
    ...

经过这些更改,我们将能够使用本书中讨论的所有功能,从 Python 2.5 版本开始的所有版本。

对于 mock 库也是如此。mock库是在 Python 3.3 中添加到标准库的。当前的 mock 库已经回滚,并且可以从 PyPi 获取。我们可以使用以下命令安装它:

pip install mock

我们可以使用以下命令来导入它:

import mock

然后,我们可以使用本书中讨论的所有 mock 功能,以及 Python 的早期版本。

编写跨版本兼容的代码

如今,许多 Python 模块被设计为在多个 Python 版本下运行,特别是同时支持 Python 2.x 和 Python 3.x 版本。我们希望在两个版本中都运行相同的测试,为此,我们需要以使测试与两个版本兼容的方式编写我们的代码。

Python 的导入机制为我们提供了执行此操作的灵活性。在文件顶部,我们像以下这样导入unittest

try:
   import unittest2 as unittest
except ImportError:
   import unittest

这所做的首先是尝试导入unittest2。如果我们正在运行 Python 2.x,那么我们应该已经安装了它。如果成功,则模块被导入,模块引用被重命名为unittest

如果我们得到ImportError,则表示我们正在运行 Python 3.x,在这种情况下,我们可以导入标准库中捆绑的unittest模块。

在代码的后续部分,我们只需引用unittest模块,它就会正常工作。

此机制依赖于在 Python 2.x 版本中使用时始终安装unittest2模块。这可以通过将unittest2模块作为 pip 需求文件中仅针对 Python 2.x 的依赖项来实现。

对于模拟,可以使用类似的方法:

try:
   from unittest import mock
except ImportError:
   import mock

在这里,我们首先尝试导入作为unittest标准库模块一部分提供的mock库。这从 Python 3.3 版本开始可用。如果导入成功,则表示已成功导入mock库。如果失败,则意味着我们正在运行较旧的 Python 版本,因此我们直接导入从 PyPi 安装的mock库。

注意我们如何使用from unittest import mock这一行,而不是import unittest.mock。这样做是为了确保在两种情况下模块引用名称相同。一旦完成导入,我们就可以在我们的代码中引用mock模块,并且它将在 Python 各个版本中正常工作。

从命令行运行测试

在整本书中,我们使用了以下语法来运行我们的测试:

python.exe -m unittest

能够使用-m标志直接运行模块的功能是在 Python 2.7 中引入的。如果我们使用的是较旧的 Python 版本,则此语法将不起作用。相反,PyPi 中的unittest2模块包含一个unit2脚本,它模拟了这种行为。命令行参数保持不变,因此我们得到以下命令:

python3 -m unittest discover -s stock_alerter -t .

上述命令现在变为:

unit2 discover -s stock_alerter -t .

如果我们使用构建工具,检查 Python 版本并执行适当的命令就变得相当简单,从而允许开发者以统一的方式运行测试,无论使用的是哪个 Python 版本。

在这些更改到位后,我们将能够使用本书中描述的所有功能,同时能够统一支持 Python 2.x 和 Python 3.x。

运行本书中的示例

本书中的代码示例是为 Python 3.4 编写的。它们使用了在 Python 较旧版本中不可用的某些语法。因此,如果我们想在 Python 2.6 等版本上运行示例,我们需要对代码进行一些更改。

注意

以下所有更改的完整源代码可在网上找到,链接为 github.com/siddhi/test_driven_python。如果您想在此书中的 Python 2.6、2.7、3.0、3.1、3.2 或 3.3 版本下运行示例代码,请获取此代码。

需要以下更改:

  • Enum:在较老的 Python 版本中,Enum 库不是标准库的一部分。它已经被回滚并可以从 PyPi 安装。要使用此功能,请安装 Enum34 库。

  • set 语法:Python 的新版本支持使用单花括号简写语法创建 set 对象,如 {"MSFT"}。在较老的版本中,我们需要使用等效的长句语法显式创建集合:set(["MSFT"])

  • print 语句:在 Python 2.x 中,print 被定义为一条语句,因此我们不能将其作为函数调用,也不能对其进行模拟。我们可以通过在所有使用 print 的文件顶部添加一行 from __future__ import print_function 来解决这个问题。

  • builtins:在 Python 2.x 中,builtins 模块被称为 __builtin__。因此,当我们想要模拟 printopen 函数时,我们需要使用 __builtin__.print__builtin__.open

  • yield from 表达式:此表达式在较老的 Python 版本中不可用。它必须被替换为迭代。

  • mock_open:此模拟辅助函数仅在回滚版本中模拟 read 方法。它不支持在文件对象上模拟迭代。因此,我们需要更改实现,使其不使用迭代。

通过这些更改,本书中的示例将能在 Python 2.6 及以上版本上运行。

posted @ 2025-09-19 10:36  绝不原创的飞龙  阅读(26)  评论(0)    收藏  举报