
Bugs!无论您多么小心地去编码,现在或以后,每个应用程序都可能出现错误,特别是大型企业软件。现在,很多人都有“我的代码整洁且没有 bug ”的态度,实际上即便可能,创建没有错误的代码也是异常困难的。但是,这并不意味着您应该放弃或者编写无法正常工作的槽糕代码(spaghetti code)。我们作为开发人员,应该尽最大努力编写高质量的代码。高质量意味着少量的错误。
除此之外,在生产环境中出现Bug代价是非常高的。您可能知道,修正在开发过程中发现错误的成本是修正生产过程中发现相同错误成本的 1/100 。因此,我们应该专注于尽快找到错误。我们的第一道防线是测试,也是一个很好的度量指标。
我们可以手动测试应用程序,只需运行它们并四处点击即可。然而,这种方法有很多缺陷。第一个是它很耗时,这意味着极高的成本。这种测试方式也使回归测试非常困难。
想象一下,您刚刚向应用程序添加了一项新功能。您必须确保此新功能不会破坏任何先前的功能,这意味着要从头开始测试整个应用程序,耗时且代价高昂。解决办法是什么呢?自动化测试。
在本文中,我们将介绍:
● 自动化测试
● 单元测试
● 什么是测试驱动开发
● 使用 TDD 解决问题
● Mock 对象
● Patching
1. 自动化测试
正如我们所看到的,手动测试并不真正有效,特别是如果我们想在开发阶段的早期发现问题。所以,我们决定进行自动化测试。根据测试的抽象级别,它们可以被分类为:
● 单元测试:它调用另一段代码(单元)并检查输出是否与所需的输出相同。
● 集成测试:它测试一个代码单元,但是与代码有关的其他部分不会被完全控制。代码单元可能使用一个或多个外部依赖项,例如数据库、线程、网络、时间等。
● 功能测试:它测试系统功能的一部分,将功能作为一个黑箱进行观察,并根据功能规范验证系统的输出。
● 验收测试:测试系统是否满足预期的业务和合同要求。它将系统视为一个黑箱。

定义是相对松散的,因为每个人可能对不同类型的测试使用不同的名称。例如,有些人使用“开发人员测试”这个名称而不是“单元测试”,因为这些测试是开发人员可以阅读和理解的。本文中没有用其他容易引起混淆的名称,重要的是您明白这一点就可以了。
在上图中,您可以看到测试金字塔。它显示了我们的应用程序中每种类型的测试应该有的测试数量。可以看到数量最多的是单元测试,本文我们也只考虑这种类型的测试,因为它们对 TDD 至关重要。
2. 单元测试
如前所述,单测是测试一个代码单元的功能。这给我们带来了一个哲学问题,即“代码单元”到底是什么?被测的代码单元完成了一系列的方法调用并最终产生一个明显的结果。同样哲学的答案,对吧?这里要理解的一点是,单元测试是测试另一段代码的代码。多年来,这种类型的测试被证明是提高软件质量的最佳工具之一。它们是由 Kent Back 在 1970 年代的 Smalltalk 中引入,从那时起它们几乎被用于任何编程语言。

首先,我们创建 FirstTestClass 类,它继承自 unittest 模块中的 TestCase 类。通过使用继承,我们定义了一个包含我们的测试方法或测试用例的测试类。这些测试用例注册在 unittest 模块中,稍后我们就能够运行它们。在这个类中,我们只有一个测试用例 test_upper。
此方法使用 assertEqual 函数来验证对字符串的 upper 方法确实返回了相同字符串的大写字母。unittest 模块中有很多以 assert 开头的方法。
本质上说,每个测试方法都应该调用这些方法之一来验证结果,然后 runner 可以累积所有测试结果并生成报告。最后,在这个文件的末尾,我们调用 unitest.main 运行所有注册的测试用例。下面是我们运行后得到的结果:

如果我们想知道调用了哪些测试用例,您可以添加 -v 参数:

正如我们所看到的,我们运行了一个测试用例,并得到了 pass 的结果,也就是说我们使用 assertEqual检查的条件为真。恭喜,你刚刚用 Python 运行了你的第一个测试用例!现在,让我们看看如何测试我们编写的其他功能代码。看下面这段代码:

这是一个非常简单的 get_greetings 函数,它只返回 “Hello World!” 字符串。下面是我们测试这个函数的方式:

很容易,对吧?我们只是导入函数,写一个继承自 unittest.TestCase 的类,编写测试方法并在测试方法 test_get_helloworld 中使用 assertEqual 验证结果。输出如下所示:

3. 什么是测试驱动开发?
测试驱动开发 (TDD)是一种构建和设计软件的迭代方法。它由许多小循环组成,每一次循环包括编写失败的单元测试、实现最少量能够通过测试的代码、遵循一些好的原则重构代码。因为我们已经.编写了测试代码,您可以放心大胆地重构代码以及解决方案。
通过使用我称之为 TDD 小调的东西,这三个重要的 TDD 步骤很容易被记住:红色 - 绿色 - 重构。红色对应于我们编写失败测试用例的阶段,然后我们实现代码使之前编写的测试通过,这是绿色的含义,最后我们重构代码,我们没有指定代表这一步的颜色。好了,再来一遍 TDD 小调:红色、绿色、重构。

您可能好奇单元测试和 TDD 有什么区别?一般来说,我们在这两种情况下都使用单元测试。TDD 和传统测试之间的关键区别在于我们编写测试代码的时刻。当我们使用 TDD 开发方法时,我们首先写测试代码,然后写代码本身,而不是倒过来。这种方法的好处是我们可以最大限度地减少了忘记为代码的某些部分编写测试的可能性。理想情况下,我们最终会得到100% 测试覆盖的代码,通常使用 TDD 实现的解决方案会有 90%-100% 代码覆盖率。
当然,代码被测后系统中出现错误的可能性减小。另一个重要的区别是我们正在为通过测试书写小块代码(small chunks of code)。通过这种方式,流程本身驱动并迫使我们的设计保持简洁。通过使用 TDD,我们可以避免创建过于复杂的设计和过度设计的系统。可以说这是这种方法的最大好处,当我们使用 TDD 时,我们最终会得到更清晰的设计和 API。这种方法还迫使您正确设计类并遵循良好的代码原则,如 SOLID 和 DRY。就我个人而言,我认为它是一个伟大的拖延终结者和一个永不枯竭的动力源,它会驱使你永远保持这种状态。
4. 使用 TDD 解决问题
在我们继续之前,让我们确认一下我们正在尝试解决什么样的问题。你们喜欢《瑞克和莫蒂》吗?我非常喜欢这个电视节目。该节目讲述了愤世嫉俗的疯狂科学家瑞克和他的孙子莫蒂史密斯的冒险经历。瑞克拥有一把传送门手枪可以将莫蒂带到不同的维度/宇宙。这些角色的不同版本居住在其他维度。Citadel (城堡)是瑞克和莫蒂的同行们在无限的现实中建立社会的地方。我们很幸运,因为我们收到了城堡发来的模块需求。以下是用户故事:
● 用户可以为 Ricks and Mortys 分配一个宇宙编号
● 用户可以将居民添加到 Citadel (城堡)
● 用户可以将所有带有指定 Mortys 的 Ricks 变成泡菜(观看 s03e03)

4.1 第一个用户故事
让我们从第一个用户故事开始,直到将我们的 TDD 魔法应用到最后一个用户故事。第一个用户故事告诉我们应该有两个类,一个是 Rick,一个是 Morty。但是,由于我们使用的是 TDD,因此我们首先编写单元测试。我们像这样实现 Rick 测试类:

如果我们运行这个测试,我们会得到一个提示 Rick 类不存在错误:

我们需要定义类,然后通过构造函数的参数使 universe 的值进行初始化:

现在,重新运行测试,我们得到:

我们对 Morty 应用相同的模式,一个失败的测试:

然后是实现:

通过测试:


4.2 第二个用户故事
您可能注意到,这种“舞蹈”起初似乎不自然。习惯它需要一些时间,但一旦您习惯了它就会令您着迷。您会想多年来您是如何能以另一种方式去做到这一点的。到此,我们实现了我们的第一个用户故事。让我们进入第二个用户故事。这个也很简单:“用户可以将居民添加到 Citadel”。但是,如果我们要添加居民,这意味着 Citadel 类应该有某种类型的居民列表或数组。让我们首先创建一个返回所有居民的方法。我们为 Citadel 类编写一个测试 :

测试执行失败,因为 Citadel 类的实现尚不存在。由于我们写了下面这样的代码企图让测试通过,因此看起来比前面的实现稍微复杂一些:

如您所见,我们定义了私有字段 __residents__ 并添加了 get_all_residents 方法 ,该方法 目前不执行任何操作。这样我们可以运行我们的测试,但它还是失败了:

为了解决这个问题,我们必须通过下面方法返回该私有字段:

重新运行测试:


测试通过,我们正在向成功迈进。但是第二个用户故事要求的功能并没有完全实现。这也是我们编写另一个测试的原因,完整的 Citadel 测试类现在看起来像下面这样:


测试执行失败,因为 Citadel 类中现在还没有 add_residents 方法。让我们实现它:

现在,运行测试,我们得到:

4.3 第三个用户故事
我们已经完成了三个用户故事中的前两个。然而,最后一个是最棘手的。让我们检查一下:用户可以将所有带有指定 Mortys 的 Ricks 变成泡菜。我们可以从这一句话中挖掘到几个子任务。第一,我们应该能够将 Morty 分配给 Rick,这意味着我们需要扩展这两个类;第二,我们应该能够把 Rick 变成泡菜;第三,我们应该能够将 Citadel 中分配了 Morties 的所有 Ricks 变成泡菜。让我们按照这个顺序进行。首先,我们使用 is_assigned 字段扩展 Morty。Morty 测试类如下所示:

测试执行失败。为了通过测试,我们还必须扩展 Morty 类的实现:

再次运行测试:

我们距离成功越来越近了。现在应该扩展 Rick 类,以便可以将 Morty 分配给 Rick。我们扩展 Rick 测试类:

测试执行失败。因为 Rick 类中还没有 morty 字段。让我们扩展 Rick 类:

执行测试:

现在让我们添加对 assign 方法的测试,通过 assign 方法我们将 Morty 分配给 Rick:


如您所见,在分配 Morty 后我们检查两件事。当我们完善 Rick 类以支持这些更改时, Rick 类的实现看起来应该像下面这样:

当我们重新运行 Rick 类的测试时 :


不要放弃,我们即将完成第三个用户故事!我们还需要的是让 Rick “可被挑选”,并将城堡中所有指定了莫蒂的瑞克变成泡菜(我从没想过我会写下这样的句子:))。完善 Rick 测试类:


test_has_is_pickle 测试执行失败,因为 Rick 类还没有 is_pickle 字段。Rick 类需要为此进行扩展:

现在,到 Citadel 测试类。我们添加了一个比较长的测试:



在新添加的测试中,我们创建了所有必要的对象,将 Morty 分配给 Rick,将两个对象添加到 Citadel 并调用一个方法,该方法应该将所有带有 Mortys 的 Ricks 变成泡菜。很酷,让我们完善 Citadel 类的实现:

重新运行测试:

Woooohoooo!测试通过,我们完成了第三个也是最后一个用户故事!

5. Mock 对象
为了完全掌握 TDD,我们需要知道如何 mock 对象。那么这意味着什么呢?在面向对象编程中,mock 对象被定义为模拟对象。它们以受控方式模仿真实对象的行为。为什么会有人这样做?单元测试的关键将被测代码独立于其他某些功能(单元),只测试想要测试的功能(单元)。因此,我们使用 mock 对象尝试删除所有其他依赖项。
依赖可以是数据库或文件系统,因此使用 mock 对象避免踏入集成测试的范畴。否则,我们就需要在每次测试前后处理或恢复数据库中的数据,而这正是我们想要避免的。其他时候依赖可以只是其他一些类或函数。我们使用 mock 对象的原因就是帮助实现我们的目标:隔离被测单元。
在以下情况下应使用 mock 对象:
● 真实对象执行慢(耗时)
● 真实对象资源有限,难以手动创建
● 真实对象产生非确定性的结果
● 真实对象尚不存在(在 TDD 中通常是这种情况)

5.1 Python 中的 mock 对象
出于本文介绍 TDD 的目的,我们使用 unittest 模块。更具体地说,我们使用 Mock 类。本质上,这个类是在测试方法中创建存根的核心类。对这些 mock 对象执行操作后,您可以检查各种详细信息,这可以帮助您确定您的功能是否正常运行。使用 Mock 类的对象,您可以模拟任何函数、变量或对象。看看这个测试:

注意,您可以定义和访问 Mock 对象的任何“字段”,访问字段将返回 Mock 类的一个对象。除此之外,您也可以将此 mock 对象视为函数。这意味着您可以以任何您想要的方式准备这个 mock 对象。例如,您可以为该对象的字段分配一些值。您可以通过直接分配字段的值或使用 configure_mock 方法为字段设置值:

5.2 Python 中的模拟函数
当然,您也可以配置 mock 方法。例如,如果你想让 mock 对象有一个返回特定值的方法,你可以这样做:


我们用 return_value 选项。但是,有时我们故意想测试失败路径。例如,我们希望方法抛出一个异常,可以这样配置:

side_effect 选项是一个很重要的特性。我们也可以用它做其他事情,例如,如果你想在每次调用函数时返回不同的值,这个选项可以帮助到你:

使用 Mock 类,我们可以验证某些方法是否已经被调用:

更进一步,我们可以检查某个函数是被调用一次还是多次:


这在将一个对象注入另一个对象然后验证前者的某些函数是否被调用的情况下非常有用。最后,我们可以使用 reset_mock 重置其中一些计数器。注意,这种方式我们只是重置函数调用计数,而不会影响配置本身:

以上是我们可以用 Mock 类做的一些简单操作。但是,现在仍然没有回答如何 mock 我们自己定义的类或来自某些第三方库的类,这些事情是使用 patch 完成的。
6. Patching
Patch 也不难,基本上,我们使用它来 mock 不应在单元测试中调用的系统部分。简而言之,我们要将依赖同被测单元隔离。我们可以通过这种方式 mock 几乎任何东西。让我们看一下 path.py 文件中的这段代码:

在我们对 current_path 函数的单元测试中,我们并不想真的去调用 os 方法,因为它对我们的功能测试来说不是必需的,而且返回值可能因环境而异。这就是我们谈到的一些第三方或导入的依赖,我们要模拟并删除该特定依赖项。这就是 patch 方法所能做到的事情:

使用 patch 方法,我们创建了一个 os_mocked 对象来处理这个调用。留意我们在 'path.os' 上创建 patch 的方法。这样我们就可以 mock 来自任何 import 的任何对象。patch 的另一种书写方式是:

注意我们传递给测试方法的附加参数。这个附加参数保存了我们的 mock 对象。在本文中,我们将使用后者书写方式。如果我们想模拟上下文管理器,我们也可以使用相同的方法。让我们在 path.py 添加读取文件的函数:


现在,我们要模拟打开上下文管理器,因为我们不想在单元测试中真正打开文件。如果那样做,可能会很耗时,而且还需要在内存中加载大量数据。所以,我们使用 patch:

我们使用 StringIO 的类对象作为 open 上下文管理器的返回值。除了 __enter__ 方法之外,这里使用的其他方法之前都见过。
Patch 也可以应用于我们系统中的类。如果您还记得,在前一章中,我们编写了的 Rich 类和 Morty 类,其中一项功能是我们可以将 Morty 分配给 Rick。下面是这些类的样子:

这里提醒一下,Rick 测试类中的 assign 过程的测试如下所示:

这可能不是最好的测试方法。我们在同一个测试中创建了两个不同类的对象,因此有些人可能会说,这更像是一个集成测试而不是单元测试;而另外有些人可能会说,如果我们将整个分配过程定义为一个测试单元,那么这就是一个单元测试。两种说法可能都是对的,但是,假如我们希望 Rick 类有尽可能高的隔离程度,这意味着我们需要模拟 Morty 对象。为了尽可能高地将 Rick 与依赖隔离,我们修改测试:

简而言之,我们使用 patch 来模拟 Morty 对象,并在这个 mock 对象上做同样的检查。
我们可以用 Mock 类做的另一件很酷的事情是模拟某个类的某些部分,即部分mock(mock partially)。下面代码实现了只模拟 Rick 类中的 assign 方法:

通过这种方式,您可以更好地将被测单元与依赖进行隔离。
结论
在本文中,我们了解了几个概念。我们探索了存在什么样的自动化测试。我们专注于单元测试,因为它们是测试驱动开发的支柱,我们也对此进行了解释。最后,我们使用这种技术实现了一个解决方案。您可能会注意到我们没有进行大量重构,因为示例非常简单。但是,我们可以感受到这种开发方式如何推动我们实现,以及它如何迫使我们书写整洁且可测试的代码。
如果我们想要被测单元与外界独立,mock 对象是必不可少的步骤。因此,mock 在测试驱动开发中至关重要。在这篇文章中,我们有已经看到 mock 类是如何在单元测试中被使用。我们看到了其他各种需要 mock 的场景以及在这些情况下如何使用 patch 方法。我们也探索了mock 第三方类、上下文管理器、自定义类,甚至部分 mock 某些类的方法。
作者

Nikola M. Zivkovic (CAIO at Rubik's Code)
Nikola M. Zivkovic 是Rubik's Code的 CAIO和《Ultimate Guide to Machine Learning and Deep Learning for Programmers》的作者。他喜欢知识分享,也是一位经验丰富的演讲者,并在诺维萨德大学担任客座讲师。
感谢您的阅读。
{测试窝原创译文,译者:lukeaxu}