理解何时适合使用测试替身

正如我们所看到的,模拟对象是一种有用的测试替身。但它们并不总是正确的方法。在某些情况下,我们应该积极避免使用模拟对象。这些情况包括过度使用模拟对象、对你不拥有的代码使用模拟对象以及模拟值对象。我们接下来将看看这些情况。然后,我们将总结模拟对象通常有用的地方的一般建议。

让我们首先考虑当我们过度使用模拟对象时引起的问题。

避免过度使用模拟对象

乍一看,使用模拟对象似乎为我们解决了许多问题。然而,如果不加小心地使用,我们最终可能会得到非常低质量的测试。为了理解为什么,让我们回到 TDD 测试的基本定义。它是一个验证行为并且独立于实现的测试。如果我们使用模拟对象来替代真正的抽象,那么我们就是在遵守这一点。

潜在的问题发生是因为为实现细节而不是抽象创建模拟对象太容易了。如果我们这样做,我们最终会将我们的代码锁定在特定的实现和结构中。一旦测试与特定的实现细节耦合,那么更改该实现就需要更改测试。如果新实现与旧实现具有相同的结果,测试实际上应该仍然通过。依赖于特定实现细节或代码结构的测试会积极阻碍重构和添加新功能。

不要模拟你不拥有的代码

另一个不应该使用模拟对象的领域是作为团队外部编写的具体类的替代品。假设我们使用一个名为 PdfGenerator 的库类来创建 PDF 文档。我们的代码将调用 PdfGenerator 类上的方法。我们可能会认为,如果我们使用模拟对象来替代 PdfGenerator 类,测试我们的代码会很容易。

这种方法有一个问题,只会在未来出现。外部库中的类很可能会发生变化。假设 PdfGenerator 类删除了我们的代码正在调用的一个方法。即使没有其他原因,我们也必须在某个时候更新库版本作为我们安全政策的一部分。当我们拉入新版本时,我们的代码将不再针对这个更改后的类编译——但我们的测试仍然会通过,因为模拟对象中仍然有旧方法。这是我们为代码的未来维护者设下的一个微妙陷阱。最好避免这种情况。一个合理的方法是包装第三方库,并理想地将其放在接口后面以倒置对它的依赖,完全隔离它。

不要模拟值对象

值对象是一个没有特定身份的对象,它仅由其包含的数据定义。一些例子包括整数或字符串对象。如果两个字符串包含相同的文本,我们认为它们是相同的。它们可能是内存中的两个单独的字符串对象,但如果它们持有相同的值,我们认为它们是相等的。

在 Java 中,某物是值对象的线索是存在定制的 equals()hashCode() 方法。默认情况下,Java 使用对象的身份来比较两个对象的相等性——它检查两个对象引用是否指向内存中的同一个对象实例。我们必须覆盖 equals()hashCode() 方法,以根据值对象的内容提供正确的行为。

值对象是一个简单的东西。它的方法内部可能有一些复杂的行为,但原则上,值对象应该易于创建。创建一个模拟对象来替代这些值对象之一没有任何好处。相反,创建值对象并在测试中使用它。

没有依赖注入不能模拟

测试替身只能在我们能够注入它们的地方使用。这并不总是可能的。如果我们想要测试的代码使用 new 关键字创建了一个具体类,那么我们不能用替身替换它:

package examples;

public class UserGreeting {
    private final UserProfiles profiles = new UserProfilesPostgres();

    public String formatGreeting(UserId id) {
        return String.format("Hello and welcome, %s", profiles.fetchNicknameFor(id));
    }
}

我们看到 profiles 字段已经使用具体类 UserProfilesPostgres() 进行了初始化。使用这种设计无法直接注入测试替身。我们可以尝试使用 Java 反射来解决这个问题,但最好将其视为对我们设计限制的 TDD 反馈。解决方案是允许注入依赖,正如我们在前面的示例中看到的那样。

这通常是遗留代码的问题,遗留代码只是在我们工作之前编写的代码。如果这段代码创建了具体对象——并且代码无法更改——那么我们不能应用测试替身。

不要测试模拟

测试模拟是一个短语,用于描述在测试替身中内置了太多假设的测试。假设我们编写了一个存根来替代某些数据库访问,但该存根包含数百行代码来模拟对该数据库的详细特定查询。当我们编写测试断言时,它们都将基于我们在存根中模拟的那些详细查询。

这种方法将证明 SUT 逻辑对这些查询的响应。但我们的存根现在对真实数据访问代码的工作方式做出了很多假设。存根代码和真实数据访问代码很快就会脱节。这导致了一个无效的单元测试,它通过了,但存根响应在现实中不再可能发生。

何时使用模拟对象

每当我们的 SUT 使用推送模型并从某个其他组件请求操作时,模拟对象是有用的,其中没有明显的响应,例如:

  • 从远程服务请求操作,例如向邮件服务器发送电子邮件

  • 从数据库中插入或删除数据

  • 通过 TCP 套接字或串行接口发送命令

  • 使缓存失效

  • 将日志信息写入日志文件或分发日志端点

在本节中,我们学习了一些技术,使我们能够验证是否请求了操作。我们已经看到如何再次使用依赖倒置原则来允许我们注入一个我们可以查询的测试替身。我们还看到了一个手工编写代码的示例。但是我们必须总是手工编写测试替身吗?在下一节中,我们将介绍一个非常有用的库,它为我们完成了大部分工作。