编写可测试的代码

已发表: 2022-11-03

单元测试是任何软件开发人员工具箱中必不可少的工具。 在处理遵循最佳软件设计实践、模式和原则的代码库时,编写单元测试相对容易。 真正的问题出现在尝试对设计不佳、无法测试的代码进行单元测试时。

本博客将讨论如何编写更多可单元测试的代码,以及避免哪些模式和不良做法以提高可测试性。

可测试和不可测试的代码

在处理需要长期维护的大型应用程序时,我们必须依靠自动化测试来保持系统的整体质量高。 与将多个单元作为一个整体进行测试的集成测试相比,单元测试具有快速且稳定的优势。 快速是因为我们正在实例化(理想情况下)只是被测试的类,并且稳定是因为我们通常会模拟外部依赖项,例如数据库或网络连接。

如果您不熟悉单元测试和集成测试之间的确切区别,可以在我们的测试简介博客中阅读有关此主题的更多信息。

可测试的代码可以与我们代码库的其余部分隔离开来。 换句话说,可以独立测试最小的单元。 不可测试代码的编写方式很难,甚至不可能为它编写一个好的单元测试。

让我们回顾一下在编写可测试代码时应该避免的一些反模式和不良做法。

示例是用 Java 编写的,但这里提到的编码约定适用于任何面向对象的编程语言和测试框架。 在这篇博文中,我们将使用 assertj 和 JUnit5 作为示例。

依赖注入

依赖注入是实现测试隔离最重要的设计模式之一。 依赖注入是一种设计模式,其中一个对象通过构造函数参数或设置器接收其他对象(依赖项),而不必自己构造它们。

通过依赖注入,我们可以通过模拟对象的依赖关系轻松隔离被测类。

我们来看一个没有依赖注入的例子:

由于引擎依赖是在 Car 类的构造函数中构建的,因此可以说 Car 和 Engine 类是紧密耦合的。 他们高度依赖彼此——改变一个就需要改变另一个。

从测试的角度来看,您不能单独测试 Car 类,因为上面的示例无法用测试替身替换具体的 Engine 实现。

但是,我们可以使用依赖注入和多态来实现隔离:

现在我们可以构建多个引擎实现,因此可以构建具有不同引擎的汽车:

现在可以进行隔离测试,因为我们可以创建 Engine 抽象的模拟实现并将其传递给我们的 Car 类:

在处理需要其他对象(依赖)的对象时,您应该通过构造函数参数(依赖注入)来提供它们,理想情况下隐藏在一些抽象之后。

通过遵循这种模式,您的代码变得更具可读性,并且可以随着时间的推移而变化。 此外,您应该避免在构造函数中进行实际工作——除了字段分配之外的任何事情都是实际工作。 构造函数中的 `new` 关键字始终是不可测试代码的警告标志。

这里要注意的一点是,在某些情况下,紧密耦合(例如,构造函数中的新关键字、用于逻辑隔离的内部类、对象映射器)并不是一个坏习惯——作为“独立”类没有意义的类。

全局状态

共享全局状态通常会产生不稳定的测试(有时通过,有时失败),尤其是在多线程环境中。

想象一个场景,多个被测对象共享相同的全局状态——如果其中一个对象中的方法触发改变共享全局状态值的副作用,则另一个对象中的方法的输出变得不可预测。 避免使用不纯的静态方法,因为它们会以某种方式改变全局状态或被代理到某些全局状态。

我们来看看这个不纯的静态方法:

本质上,此方法读取当前系统日期和时间并根据该值返回结果。 为这个方法编写一个适当的基于状态的单元测试是非常困难的,因为 LocalDateTime.now() 静态调用会在我们的测试执行期间产生不同的结果。 如果不更改系统日期和时间,就不可能为此方法编写测试。

为了解决这个问题,我们将日期时间作为参数传递给 timeOfDay 方法:

timeOfDay 静态方法现在是纯的——相同的输入总是产生相同的结果。 现在我们可以轻松地将孤立的 dateTime 对象作为测试中的参数传递:

得墨忒耳法则

得墨忒耳定律,或最少知识原则,规定对象应该只知道与第一个对象密切相关的对象。 换句话说,一个对象应该只能访问它需要的对象。 例如,我们有一个接受上下文对象作为参数的方法:

这种方法违反了得墨忒耳定律,因为它需要遍历一个对象图来获取所需的信息来完成它的工作。 将不必要的信息传递给类和方法会损害可测试性。

想象一个巨大的 BillingContext 对象,其中包含对其他对象的引用:

正如我们所看到的,我们的测试充满了无关紧要的信息。 创建复杂对象图的测试很难阅读并引入了不必要的复杂性。

让我们修复之前的示例:

您应该始终将直接依赖项传递到您的类和方法中。 但是,将许多参数传递给方法也不是一个好习惯——理想情况下,您应该传递最大的两个参数或将密切相关的参数包装到数据对象中。

上帝的对象

上帝对象是一个引用许多其他不同对象的对象,具有多个职责,并且有多个更改原因。 如果总结班级所做的事情具有挑战性,或者如果总结包含“和”这个词,那么班级可能有不止一项责任。

上帝对象很难测试,因为我们正在处理多个不相关的依赖关系,混合了不同级别的抽象和关注点,并且它们会产生很多副作用。 因此,我们的测试用例很难达到预期的状态。

例如:

UserService 的职责不止一项——注册新用户和发送电子邮件。 在测试用户注册时,我们需要处理电子邮件服务,反之亦然:

想象一个 UserService 具有两个以上不相关的依赖项。 这些依赖有自己的依赖,以此类推。 我们最终会得到一个不可读的测试,其中包含不相关的信息并且非常难以理解。 因此,每个班级应该只有一个责任和改变的理由。 只有一个更改理由的类是称为单一职责原则的五项软件设计原则之一。

您可以在此处阅读有关 SOLID 原则的更多信息。

结论

遵循软件设计最佳实践的代码库使编写单元测试更易于管理。

另一方面,使用上述反模式为代码库编写工作单元测试可能非常具有挑战性,有时甚至是不可能的。 编写好的可测试代码需要大量的实践、纪律和额外的努力。 可测试代码最显着的优势是易于测试以及理解、维护和扩展该代码的能力。

我们希望这个博客可以帮助您编写可测试的代码。