首先添加测试

在这一部分,我们将回顾在编写生产代码之前先添加测试的权衡。

前面的章节遵循了测试优先的方法来编写代码。我们在编写生产代码之前编写测试,以使该测试通过。这是一种推荐的方法,但重要的是要理解与之相关的一些困难,同时也要考虑其好处。

先测试是一个设计工具

编写测试的最重要好处是测试可以作为一种设计辅助工具。当我们决定在测试中编写什么内容时,我们正在设计代码的接口。每个测试阶段都帮助我们考虑软件设计的一个方面,如下图所示:

image 2025 01 12 18 31 06 852
Figure 1. Figure 12.1 – Test-first aids design

Arrange 步骤帮助我们思考被测代码如何与整个代码库的大局相关联。这一步帮助我们设计代码如何适应整个代码库。它为我们提供了做出以下设计决策的机会:

  • 需要哪些配置数据?

  • 需要与其他对象或函数的哪些连接?

  • 这段代码应提供什么行为?

  • 提供该行为需要哪些额外的输入?

编写 Act 步骤的代码使我们能够思考代码的易用性。我们反思我们希望设计的代码的方法签名是什么样子的。理想情况下,它应该是简单且明确的。一些一般建议如下:

  • 方法名称应描述调用该方法的结果。

  • 尽可能少地传递参数。可能将参数分组到它们自己的对象中。

  • 避免使用布尔标志来修改代码的行为。使用具有适当名称的单独方法。

  • 避免需要多次方法调用来完成一件事。如果我们不熟悉代码,很容易在序列中遗漏一个重要调用。

编写 Act 步骤使我们能够看到第一次调用代码时的样子。这为我们提供了在代码被广泛使用之前简化和澄清的机会。

Assert 步骤中的代码是我们代码结果的第一个消费者。我们可以从这一步判断这些结果是否容易获得。如果我们对 Assert 代码的样子不满意,这是一个机会来审查我们的对象如何提供其输出。

我们编写的每个测试都提供了这种设计审查的机会。TDD 的核心是帮助我们揭示更好的设计,甚至比测试正确性更重要。

在其他行业,如汽车设计,通常有专门的设计工具。AutoCAD 3D Studio 用于在计算机上创建汽车底盘的 3D 模型。在我们制造汽车之前,我们可以使用该工具预先可视化最终结果,通过空间旋转并从多个角度查看它。

主流商业软件工程在设计工具支持方面远远落后。我们没有类似于3D Studio的工具来设计代码。20世纪80年代到2000年代见证了计算机辅助软件工程(CASE)工具的兴起,但这些工具似乎已经不再使用。CASE 工具声称通过允许用户输入各种图形形式的软件结构来简化软件工程,然后生成实现这些结构的代码。如今,在编写生产代码之前编写 TDD 测试似乎是我们目前最接近软件计算机辅助设计的东西。

测试形成可执行的规范

测试代码的另一个优势是它可以形成一种高度准确、可重复的文档形式。要实现这一点,测试代码需要简洁明了。我们不需要编写测试计划文档,而是将 TDD 测试编写为代码,可以由计算机运行。这对开发者来说更加直接。这些可执行的规范与它们测试的生产代码一起被捕获,存储在源代码控制中,并持续提供给整个团队。

进一步的文档是有用的。诸如 RAID 日志(记录风险、行动、问题和决策)和 KDD(记录关键设计决策)之类的文档通常是必需的。这些是不可执行的文档。它们的目的是捕获谁、何时以及关键的是为什么做出了重要决策。这类信息无法通过测试代码捕获,这意味着这些文档具有价值。

先测试提供有意义的代码覆盖率指标

在编写生产代码之前编写测试为每个测试赋予了特定的目的。测试的存在是为了驱动代码中的特定行为。一旦我们使这个测试通过,我们就可以使用代码覆盖率工具运行测试套件,该工具将输出类似于以下的报告:

image 2025 01 12 18 32 51 243
Figure 2. Figure 12.2 – Code coverage report

代码覆盖率 工具在我们运行测试时对我们的生产代码进行检测。这种检测捕获了在运行测试期间执行了哪些代码行。该报告可以通过标记在测试运行期间从未执行的代码行来提示我们缺少测试。

图像中的代码覆盖率报告显示,我们的测试运行已经执行了领域模型中 100% 的代码。达到 100% 的覆盖率完全归功于我们在编写代码之前编写了 TDD 测试。在测试优先的 TDD 工作流程中,我们不会添加未经测试的代码。

当心将代码覆盖率指标作为目标

高代码覆盖率指标并不总是表示高代码质量。如果我们为生成的代码或从库中提取的代码编写测试,那么这种覆盖率并不能告诉我们任何新的信息。我们通常可以假设我们的代码生成器和库已经由它们的开发者进行了测试。

然而,当我们强制要求代码覆盖率作为指标时,就会出现一个真正的问题。一旦我们对开发者施加了最低覆盖率目标,那么古德哈特定律就会适用——当一项指标成为目标时,它就不再是一个好的指标。在压力下,人类有时会作弊以达到目标。当这种情况发生时,你会看到如下代码:

public class WordTest {
    @Test
    public void oneCorrectLetter() {
        var word = new Word("A");
        var score = word.guess("A");

        // assertThat(score).isEqualTo(CORRECT);
    }
}

注意到 assertThat() 前面的注释符号 // 了吗?这是一个测试用例失败的标志,并且无法在某个截止日期前通过。通过保留测试,我们保持了测试用例的数量,并保持了代码覆盖率的百分比。这样的测试将执行生产代码的行,但不会验证它们是否有效。代码覆盖率目标将达到——即使代码本身并不工作。

现在,我知道你在想什么——没有开发者会这样作弊测试代码。然而,这是我为一个主要国际客户工作的项目中的一个例子。客户雇佣了我所在的公司和另一个开发团队来开发一些微服务。由于时区差异,另一个团队会在我们团队睡觉时提交他们的代码更改。我们一天早上进来时,看到我们的测试结果仪表板一片红色。夜间的代码更改导致我们的大量测试失败。我们检查了另一个团队的流水线,惊讶地发现他们的所有测试都通过了。这毫无意义。我们的测试清楚地揭示了夜间代码提交中的缺陷。我们甚至可以从测试失败中定位它。这个缺陷应该在该代码的单元测试中显示出来,但那些单元测试却通过了。原因是什么?注释掉的断言。

另一个团队在交付压力下。他们遵守了指令,在那一天提交了代码更改。事实上,这些更改破坏了他们的单元测试。当他们无法在可用时间内修复它们时,他们选择作弊并将问题推迟到另一天。我不确定我是否责怪他们。有时,100% 的代码覆盖率和所有测试通过并不意味着什么。

当心提前写所有测试

TDD 的优势之一是它允许涌现式设计。我们做一小部分设计工作,捕获在一个测试中。然后我们做下一小部分设计,捕获在一个新测试中。我们在这个过程中进行不同深度的重构。通过这种方式,我们了解到我们的方法中哪些有效,哪些无效。测试为我们的设计提供了快速反馈。

这只有在一次编写一个测试时才能发生。熟悉瀑布项目方法的人可能会将测试代码视为一个巨大的需求文档,在开发开始之前完成。虽然这似乎比在文字处理器中编写需求文档更有希望,但它也意味着开发者无法从测试反馈中学习。没有反馈循环。应避免这种测试方法。通过采用增量方法可以获得更好的结果。我们一次编写一个测试,并编写生产代码以使该测试通过。

先写测试有助于持续交付

也许先编写测试的最大好处在于持续交付的情况。持续交付依赖于高度自动化的流水线。一旦代码更改被推送到源代码控制,构建流水线就会启动,所有测试都会运行,最后进行部署。

在这个系统中,代码不部署的唯一原因——假设代码编译通过——是测试失败。这意味着我们现有的自动化测试是必要的,并且足以创建所需的信心水平。

先编写测试并不能保证这一点——我们可能仍然缺少测试——但在所有与测试相关的工作方式中,它可能是最有可能为我们关心的每个应用程序行为生成一个有意义的测试的方式。

这一部分提出了先编写测试——在编写生产代码之前,使其通过——有助于建立对我们代码的信心,以及生成有用的可执行规范。然而,这并不是唯一的编码方式。事实上,我们将看到的一种常见方法是先编写一大块代码,然后在不久之后编写测试。

下一部分将探讨测试后方法的优势和局限性。