向左移动测试与测试自动化

如果你实践敏捷开发并尝试频繁发布,那么手动测试就不是一个可扩展的选项。即使你没有实践CI/CD,只是按冲刺周期发布,运行所有必要的回归测试也会耗费大量的人力、时间和金钱。然而,正确地进行测试自动化并不容易。由QA部门或外包公司创建和维护的自动化测试,并不一定与更高的工程生产力相关(Forsgren N., Humble, J., & Kim, G., 2018,第95页)。为了提高生产力,你需要可靠的测试,这些测试应该由团队创建和维护。背后的理论是,如果开发人员维护测试,他们会编写更具可测试性的代码。

大家都知道,良好的测试组合应该是什么样的:你有大量的自动化单元测试(Level 0),较少的集成测试(Level 1),一些需要测试数据的集成测试(Level 2),以及极少的功能测试(Level 3)。这就是所谓的测试金字塔(见图12.1):

image 2024 12 27 15 32 59 389
Figure 1. 图12.1 – 测试金字塔

然而,在大多数公司中,测试组合并不是这样。有时会有一些单元测试,但大多数其他测试仍然处于非常高的层次(见图12.2):

image 2024 12 27 15 33 18 437
Figure 2. 图12.2 – 示例测试组合

这些高层次的测试可能是自动化的,也可能是手动的。但无论如何,这并不是一个能够帮助你高质量持续发布的测试组合。为了实现持续的质量,你必须将测试组合向左移动(见图12.3):

image 2024 12 27 15 33 37 185
Figure 3. 图12.3 – 向左移动测试

这并不是一项简单的任务。以下是一些有助于实现向左移动测试的原则:

  • 所有权:团队对质量负责,测试应与代码一起开发——最好采用先测试后编码的方法。QA工程师应融入团队。

  • 向左移动:测试应始终写在尽可能低的层次。

  • 写一次——在所有环境中执行:测试应在所有环境中执行,包括生产环境。

  • 测试代码是生产代码:测试代码应遵循与常规代码相同的质量标准。这里不允许有任何妥协。

  • 你编写代码——你测试它:作为开发人员,你对代码质量负责,必须确保所有必要的测试都已到位,确保质量。

2013年,发布了一个测试宣言,描述了QA角色的转变(Sam Laing, 2015):

  • 全过程测试,而不是末尾测试

  • 防止缺陷,而不是查找缺陷

  • 理解测试,而不是检查功能

  • 构建最佳系统,而不是打破系统

  • 团队负责质量,而不是仅仅由测试人员负责

这听起来简单,但实际上并不容易。开发人员需要学会像测试人员一样思考,测试人员也需要学会像工程师一样思考。推销这一愿景并确保这一变化的可持续性并不是一件简单的事。

测试驱动开发

测试自动化的关键是拥有可测试的软件架构。要做到这一点,必须尽早开始——也就是说,在内循环阶段,即开发人员编写代码时。

测试驱动开发(TDD)是一种软件开发过程,在这个过程中,你先编写自动化测试,然后编写使测试通过的代码。它已经存在超过20年,并且在不同的研究中证明了其质量效益(例如,Müller, Matthias M.; Padberg, Frank, 2017 和 Erdogmus, Hakan; Morisio, Torchiano, 2014)。TDD不仅对调试所花费的时间和整体代码质量有重大影响,而且对稳固和可测试的软件设计也有很大影响。因此,它也被称为测试驱动设计。

TDD是简单的,步骤如下:

  1. 添加或修改测试:始终从测试开始。在编写测试时,你设计了代码的结构。可能会有一段时间,你的测试无法编译,因为你调用的类和函数尚不存在。大多数开发环境支持直接从测试中创建必要的代码。此步骤完成后,代码应该能够编译并且测试可以执行。测试应该是失败的。如果测试通过,修改它或编写一个新的测试直到它失败。

  2. 运行所有测试:运行所有测试,验证只有新测试失败。

  3. 编写代码:编写一些简单的代码,使测试通过。始终运行所有测试以检查测试是否通过。在这个阶段,代码不需要很漂亮,可以允许一些捷径。只要使测试通过即可。糟糕的代码会给你提供一个思路,告诉你接下来需要什么测试来确保代码变得更好。

  4. 所有测试通过:如果所有测试都通过,你有两个选择:编写一个新测试或修改现有测试。或者,你可以重构代码和测试。

  5. 重构:重构代码和测试。由于你有一个强大的测试工具,你可以进行比平时更为极端的重构。在每次重构后,请确保运行所有测试。如果某个测试失败,撤销上一步操作并重试,直到重构步骤完成后所有测试通过。成功的重构后,你可以开始新的迭代,添加一个新的失败测试。

图12.4 显示了TDD周期的概述:

image 2024 12 27 15 35 40 094
Figure 4. 图12.4 – TDD周期

一个好的测试遵循以下模式:

  • Arrange(安排):设置测试所需的对象和被测试系统(SUT),通常是一个类。你可以使用mock和stub来模拟系统行为(有关mock和stub的更多信息,请参见Martin Fowler,2007)。

  • Act(执行):执行你想要测试的代码。

  • Assert(断言):验证结果,确保系统的状态是期望的状态,并确保方法调用了正确的方法,并且使用了正确的参数。

每个测试应该是完全自足的——也就是说,它不应该依赖于前一个测试操控过的系统状态,并且能够独立执行。

TDD也可以在结对编程中使用。这被称为“乒乓结对编程”。在这种结对编程形式中,一位开发人员编写测试,另一位开发人员编写使测试通过的代码。这是结对编程的一个很好的模式,也是教导年轻同事TDD好处的好方法。

TDD已经存在这么久,实践它的团队获得了如此大的价值——然而,我仍然遇到许多没有使用它的团队。有些团队没有使用它,因为他们的代码运行在嵌入式系统上,其他团队则因为他们的代码依赖于难以mock的SharePoint类而没有使用它。但这些都只是借口。可能有一些管道代码无法测试,但当你编写逻辑时,你总是可以先编写测试。

管理您的测试组合

在TDD的实践中,你应该能够迅速获得可测试的设计。即使是在旧有环境(brownfield)中,自动化测试的数量也会迅速增长。问题是,测试的质量往往不是最优的,而且随着测试组合的增长,执行时间往往会变得非常长,并且会出现非确定性的(不稳定的)测试(flaky tests)。最好是拥有较少但质量更高的测试。长时间的执行会妨碍你快速发布,而不稳定的测试则会产生不可靠的质量信号,从而降低对测试套件的信任(见图12.5)。随着团队QA成熟度的提高,测试套件的质量会不断上升——即使在第一个高峰之后,测试数量会减少:

image 2024 12 27 15 36 48 877
Figure 5. 图12.5 – 自动化测试的数量与质量

为了积极管理你的测试组合,你应该为测试定义基本规则,并不断监控测试的数量和执行时间。举个例子,下面是微软一个团队用于管理测试组合的分类法。

单元测试(Level 0)

在这一层,我们使用内存中的单元测试,没有外部依赖,也不涉及部署。它们应该非常快速,平均执行时间应少于60毫秒。单元测试与被测试代码共存。 单元测试不能改变系统状态(如文件系统或注册表),不能查询外部数据源(如Web服务和数据库),也不能使用互斥锁、信号量、计时器和 Thread.sleep 等操作。

集成测试(Level 1)

这一层涉及需要轻量级部署和配置的复杂测试要求。测试仍然应该非常快速,每个测试的执行时间必须小于2秒。 集成测试不能依赖其他测试,也不能存储大量数据。一个测试程序集内不能有太多测试,否则会阻碍测试的并行执行。

带数据的功能性测试(Level 2)

功能性测试在可测试的部署环境上运行,使用测试数据。对系统的依赖(如身份验证提供者)可以通过模拟(stub)来处理,并允许使用动态身份。这意味着每个测试都有一个独立的身份,确保测试可以在多个部署环境中并行执行,互不干扰。

生产环境测试(Level 3)

生产环境测试在生产环境中运行,并需要进行完整的产品部署。 这只是一个示例,你的分类法可能会根据编程语言和产品的不同而有所不同。

如果你已经定义了自己的分类法,就可以开始报告,并逐步转化你的测试组合。确保首先轻松地编写和执行高质量的单元测试和集成测试。接下来,开始分析你的遗留测试——无论是手动测试还是自动化测试——并检查哪些可以丢弃。将其他测试转换为良好的功能性测试(Level 2)。最后一步是编写生产环境测试。

微软的团队从27,000个遗留测试(橙色)开始,经过42个迭代(126周)减少到零。大部分测试被单元测试替代,部分被功能性测试替代,还有一些测试被删除,但单元测试逐渐增长,最终达到了40,000多个(见图12.6):

image 2024 12 27 15 38 07 540
Figure 6. 图12.6 – 测试组合随时间变化

在 “进一步阅读” 部分的《Shift left to make testing fast and reliable》一文中,可以了解微软团队如何将他们的测试组合向左移动的详细信息。