编写单元测试时,不要使用 Mocks

已发表: 2018-05-02

注意:这是我们最新的技术工程帖子,由首席工程师 Seth Ammons 撰写。 特别感谢Sam Nguyen、 Kane Kim、Elmer Thomas 和 Kevin Gillette 对本文的同行评审 如果有更多这样的帖子,请查看我们的技术博客。

我真的很喜欢为我的代码编写测试,尤其是单元测试。 它给我的信心是伟大的。 拿起我很久没做过的东西并能够运行单元和集成测试让我知道如果需要我可以无情地重构,只要我的测试有良好和有意义的覆盖率并继续通过,以后我还会有功能软件。

单元测试指导代码设计,使我们能够快速验证故障模式和逻辑流程是否按预期工作。 有了这个,我想写一些可能更具争议性的东西:在编写单元测试时,不要使用模拟。

让我们在桌子上得到一些定义

单元测试和集成测试有什么区别? 我所说的模拟是什么意思,你应该用什么来代替? 这篇文章的重点是在 Go 中工作,所以我对这些词的看法是在 Go 的上下文中。

当我说单元测试时,我指的是那些通过测试小代码单元来确保正确错误处理和指导系统设计的测试。 按单元,我们可以指整个包、接口或单个方法。

集成测试是您实际与依赖系统和/或库交互的地方。 当我说“模拟”时,我特别指的是“模拟对象”一词,即我们“用虚拟实现替换域代码,这些虚拟实现既模拟真实功能又强制对我们的代码行为进行断言 [1]”(强调矿)。

说得更短一点:模拟断言行为,例如:

MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")

我提倡“假货而不是假货”。

假货是一种可能包含商业行为的测试替身[2]。 假货只是适合接口的结构,是我们控制行为的一种依赖注入形式。 fake 的主要好处是它们减少了代码中的耦合,而 mock 增加了耦合,而耦合使重构更加困难 [3]。

在这篇文章中,我打算证明伪造品提供了灵活性并允许轻松测试和重构。 与模拟相比,它们减少了依赖关系,并且易于维护。

让我们来看一个比“测试求和函数”更高级的示例,正​​如您在此类典型帖子中看到的那样。 但是,我需要为您提供一些上下文,以便您可以更轻松地理解本文后面的代码。

在 SendGrid,我们的一个系统传统上在本地文件系统上有文件,但由于需要更高的可用性和更好的吞吐量,我们正在将这些文件转移到 S3。

我们有一个需要能够读取这些文件的应用程序,我们选择了一个可以在“本地”或“远程”两种模式下运行的应用程序,具体取决于配置。 许多代码示例中忽略的一个警告是,在远程故障的情况下,我们会退回到本地读取文件。

有了这个,这个应用程序就有了一个包吸气剂。 我们需要确保 package getter 可以从远程文件系统或本地文件系统获取文件。

天真的方法:只调用库和系统级调用

天真的方法是我们的​​实现包将调用getter.New(...)并将设置远程或本地文件获取所需的信息传递给它,并将返回一个Getter 。 然后,返回的值将能够使用定位远程或本地文件所需的参数调用MyGetter.GetFile(...)

这将为我们提供我们的基本结构。 当我们创建新的Getter时,我们初始化任何潜在的远程文件获取所需的参数(访问密钥和秘密),并且我们还传递了一些源自我们的应用程序配置的值,例如useRemoteFS将告诉代码尝试远程文件系统。

我们需要提供一些基本功能。 在此处查看原始代码 [4]; 下面是精简版。 请注意,这是一个未完成的示例,我们将进行重构。

这里的基本思想是,如果我们被配置为从远程文件系统读取,并且我们获得了远程文件系统的详细信息(主机、存储桶和密钥),那么我们应该尝试从远程文件系统读取。 在我们对系统远程读取有信心后,我们会将所有文件读取转移到远程文件系统,并删除对从本地文件系统读取的引用。

这段代码对单元测试不是很友好; 请注意,要验证它是如何工作的,我们实际上不仅需要访问本地文件系统,还需要访问远程文件系统。 现在,我们可以做一个集成测试并设置一些 Docker 魔术来拥有一个 s3 实例,从而允许我们验证代码中的快乐路径。

仅进行集成测试并不理想,因为单元测试通过轻松测试替代代码和故障路径来帮助我们设计更健壮的软件。 我们应该为更大的“它真的有效”类型的测试保存集成测试。 现在,让我们专注于单元测试。

我们怎样才能使这段代码更易于单元测试? 有两种思想流派。 一种是使用模拟生成器(例如 https://github.com/vektra/mockery 或 https://github.com/golang/mock)创建样板代码以在测试模拟时使用。

您可以走这条路线并生成文件系统调用和 Minio 客户端调用。 或者,也许你想避免依赖,所以你手动生成你的模拟。 事实证明,模拟 Minio 客户端并不简单,因为您有一个具体类型的客户端,它返回一个具体类型的对象。

我说有比嘲笑更好的方法。 如果我们重构我们的代码以使其更具可测试性,我们不需要额外导入模拟和相关杂物,也不需要了解额外的测试 DSL 来自信地测试接口。 我们可以将我们的代码设置为不会过度耦合,测试代码将只是使用 Go 接口的普通 Go 代码。 我们开始做吧!

接口方法:更抽象,更容易测试

我们需要测试什么? 这就是一些新的 Gophers 出错的地方。 我看到人们理解利用接口的价值,但觉得他们需要与他们正在使用的包的具体实现相匹配的接口。

他们可能会看到我们有一个 Minio 客户端,因此他们可能会首先制作与 Minio 客户端(或任何其他 s3 客户端)的所有方法和用途相匹配的接口。 他们忘记了“接口越大,抽象越弱”的 Go 谚语 [5][6]。

我们不需要针对 Minio 客户端进行测试。 我们需要测试我们可以远程或本地获取文件(并验证一些故障路径,例如远程故障)。 让我们重构最初的方法并将 Minio 客户端拉到远程 getter 中。 当我们这样做时,让我们对本地文件读取的代码做同样的事情,并创建一个本地 getter。 以下是基本接口,我们将有类型来实现每个接口:

有了这些抽象,我们就可以重构我们的初始实现。 我们将把localFetcherremoteFetcher放到Getter结构中并重构GetFile以使用它们。 在此处查看完整版本的重构代码 [7]。 以下是使用新界面版本的稍微简化的代码段:

这个新的、重构的代码更易于单元测试,因为我们将接口作为Getter结构的参数,并且我们可以更改伪造的具体类型。 我们不需要模拟操作系统调用或需要完全模拟 Minio 客户端或大型接口,我们只需要两个简单的假货: fakeLocalFetcherfakeRemoteFetcher

这些赝品有一些属性,可以让我们指定它们返回的内容。 我们将能够返回文件数据或我们喜欢的任何错误,并且我们可以验证调用GetFile方法是否按预期处理数据和错误。

考虑到这一点,测试的核心变成:

使用这种基本结构,我们可以将其全部包含在表驱动测试中 [8]。 测试表中的每个案例都将测试本地或远程文件访问。 我们将能够在远程或本地文件访问中注入错误。 我们可以验证传播的错误,文件内容是否被传递,以及预期的日志条目是否存在。

我继续并在此处提供的一个表驱动测试中包含了所有潜在的测试用例和排列 [9](您可能会注意到一些方法签名有点不同 - 它允许我们执行诸如注入记录器和断言日志语句之类的事情)。

漂亮,嗯? 我们可以完全控制我们希望GetFile的行为方式,并且可以针对结果进行断言。 我们将代码设计为对单元测试友好,现在可以验证在GetFile方法中实现的成功和错误路径。

代码是松散耦合的,未来的重构应该是轻而易举的事。 我们通过编写简单的 Go 代码来做到这一点,任何熟悉 Go 的开发人员都应该能够在需要时理解和扩展。

Mocks:关于细节、坚韧不拔的实现细节呢?

我们在提议的解决方案中没有得到什么,模拟会给我们带来什么? 展示传统模拟的好处的一个很好的问题可能是,“你怎么知道你用正确的参数调用了 s3 客户端? 使用模拟,我可以确保我将键值传递给键参数,而不是存储桶参数。”

这是一个有效的问题,应该在某个地方进行测试。 我在这里提倡的测试方法并不能验证您是否以正确的顺序使用存储桶和密钥参数调用了 Minio 客户端。

我最近读到的一句名言说:“模拟引入了假设,而假设又引入了风险 [10]”。 您假设客户端库实现正确,您假设所有边界都是可靠的,您假设您知道库的实际行为方式。

模拟库只会模拟假设并使您的测试更加脆弱,并且在您更新代码时会发生变化(这是 Martin Fowler 在 Mocks Aren't Stubs [3] 中得出的结论)。 当橡胶遇到问题时,我们将不得不验证我们实际上是否正确使用了 Minio 客户端,这意味着集成测试(这些可能存在于 Docker 设置或测试环境中)。 因为我们将同时进行单元测试和集成测试,所以不需要单元测试来涵盖确切的实现,因为集成测试将涵盖这一点。

在我们的示例中,单元测试指导我们的代码设计,并允许我们快速测试错误和逻辑流程是否按设计工作,完全按照他们需要做的事情。
对于一些人来说,他们觉得这还不够单元测试覆盖率。 他们担心以上几点。 有些人会坚持使用俄罗斯娃娃风格的界面,其中一个界面返回另一个界面,另一个界面返回另一个界面,可能如下所示:

然后他们可能会将 Minio 客户端的每个部分提取到每个包装器中,然后使用模拟生成器(向构建和测试添加依赖项,增加假设,并使事情变得更脆弱)。 最后,mockist 将能够说类似的话:

myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) - 如果你能回忆起这个特定 DSL 的正确咒语。

这将是许多与使用 Minio 客户端的实现选择直接相关的额外抽象。 当我们发现需要改变对客户的假设或完全需要不同的客户时,这将导致脆弱的测试。

这增加了现在和将来的端到端代码开发时间,增加了代码复杂性并降低了可读性,可能增加了对模拟生成器的依赖,并为我们提供了一个可疑的附加价值,即知道我们是否混淆了存储桶和关键参数无论如何,我们会在集成测试中发现。

随着越来越多的对象被引入,耦合变得越来越紧密。 我们可能已经做了一个 logger mock,然后我们开始做一个 metrics mock。 在不知不觉中,您正在添加一个日志条目或一个新指标,并且您刚刚破坏了无数测试,这些测试并不期望额外的指标通过。

上次我在 Go 中被这个问题困扰时,模拟框架甚至不会告诉我哪个测试或文件失败了,因为它遇到了一个新指标(这需要通过对测试进行注释来对测试进行二进制搜索)以便能够找到我们需要更改模拟行为的位置)。 模拟可以增加价值吗? 当然。 值得付出代价吗? 在大多数情况下,我不相信。

接口:取胜的简单性和单元测试

我们已经证明,我们可以通过在 Go 中简单使用接口来指导设计并确保遵循正确的代码和错误路径。 通过编写遵循接口的简单伪造品,我们可以看到我们不需要模拟、模拟框架或模拟生成器来创建为测试而设计的代码。 我们还注意到,单元测试并不是万能的,您必须编写集成测试以确保系统正确地相互集成。

我希望将来能获得一些关于运行集成测试的巧妙方法的帖子; 敬请关注!

参考

1: Endo-Testing: Unit Testing with Mock Objects (2000): 模拟对象的定义见介绍
2:小嘲讽者:见关于赝品的部分,具体来说,“赝品有商业行为。 你可以通过提供不同的数据来驱动假货以不同的方式行事。”
3: Mocks Aren't Stubs: 见章节,“那么我应该成为古典主义者还是模仿者?” Martin Fowler 说:“我没有看到 mockist TDD 有任何令人信服的好处,并且担心耦合测试与实现的后果。”
4:Naive Approach:简化版的代码。 见[7]。
5:https://go-proverbs.github.io/:Go Proverbs 列表以及会谈链接。
6:https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s:直接链接到 Rob Pike 关于接口大小和抽象的谈话。
7:完整版demo代码:你可以克隆repo并运行`go test`。
8:表驱动测试:一种用于组织测试代码以减少重复的测试策略。
9:测试完整版的演示代码。 您可以使用 `go test` 运行它们。
10:编写测试时要问自己的问题 Michal Charemza:模拟引入假设,假设引入风险。