消除不稳定的测试:提升软件开发效率与质量
目录
丽塔·梅·布朗(Rita Mae Brown)曾说过:
疯狂就是一遍又一遍地做同样的事情,却期待不同的结果。
然而,每个人至少都有过这样的经历:一个测试失败了,但在下一次运行时,无需更改代码或环境,它又通过了。在软件工程中,我们不称之为疯狂,我们称这种不可预测的失败为不稳定测试(Flaky Tests)。起初,它们看起来可能只是小问题,但就像杂物抽屉里的乱七八糟的东西一样,如果你从不处理,它们会随着时间的推移变得越来越糟糕。
如果我们生活在一个可以通过消除软件开发中的不稳定测试来解决全球饥饿等重大问题的世界里,会怎样?虽然这听起来可能有些夸张,但它比你想象的更接近事实,并且凸显了不稳定测试在全球开发团队中消耗的巨大成本和资源。
为什么不稳定的测试很重要?
-
浪费时间和金钱:当测试失败时,很难一眼看出是真正的 Bug,还是测试本身不稳定。开发人员往往需要重新运行测试、筛选日志并添加额外的调试代码来确认问题是否真实存在。随着时间的推移,这些持续的努力累积成了巨大的成本。布莱恩·德默斯(Brian Demers)和我在演讲《在薄冰上测试:逐步消除测试的不确定性》中估计,全球每年因不稳定测试而浪费的资金高达 360 亿美元,这与到 2030 年**消除世界饥饿所需的 400 亿美元**非常接近。
-
信任侵蚀:当更多的测试失败是由于不稳定性而非代码中的实际问题引起时,团队就会对测试套件失去信心。结果,团队开始普遍忽视测试失败,这意味着真正的测试失败也会被忽略。这会导致 Bug 溜进生产环境,并在未来引发更严重、更昂贵的问题。
-
拖慢开发进程:不稳定测试会拖慢 CI 流水线,尤其是当开发人员必须多次重新运行才能通过时。在一个项目中,我们的测试套件每次运行大约需要一个小时,但由于不稳定测试的存在,我们在每次合并请求(Merge Request)时至少需要重新运行四五次。最糟糕的是,当别人向主分支合并新代码时,我们必须重新变基(rebase)并再次尝试让流水线通过。实际上,我们大部分时间都在盯着流水线,只为了合并一个简单的更改。
就像一个你每往里扔东西就变得越发杂乱的抽屉一样,放任不稳定测试堆积,只会让你更想拖延修复它们。
导致测试不稳定的常见原因
要有效处理不稳定测试,必须首先了解它们为什么会变得不稳定。最常见的原因包括:
-
时间问题或硬编码延时: 使用固定的等待(如
Thread.sleep(500))而不是等待条件满足,会导致不可预测的失败。如果系统比预期的快或慢,测试可能会通过也可能会失败。 -
UI 竞争条件(Race Conditions): 本质上,这也是一种时间问题。如果在端到端测试中,在 UI 元素完全加载之前就与之交互,测试可能会失败。如果你使用了硬编码延时或没有进行等待,很可能会在运行较慢时遇到此问题。
-
不可靠或共享的测试数据: 在多个测试中使用相同的数据会使测试变得不可靠。例如,在一个测试中创建一个固定用户名的用户,会导致另一个尝试创建相同用户的测试失败。随机生成的数据如果违反了验证规则,第一次可能通过,第二次就会失败。例如,如果某个表单字段限制最大长度为 20 个字符,第一次生成的随机字符串是 8 个字符,测试会成功;但如果下一次运行生成了 22 个字符的文本,测试就会失败。
-
不稳定的环境: 依赖外部服务或共享资源的测试,如果服务缓慢、不可用或不可靠,就会崩溃。这在 CI 环境中尤为突出,因为那里的资源分配通常受到限制,且每次运行可能都不同。
-
测试顺序依赖: 如果一个测试依赖于另一个测试留下的状态,那么并行或乱序执行就会导致失败。每个测试都应该是能够独立运行的。
保持测试可靠性的策略
1. 建立对不稳定测试的意识
如果你不知道不稳定测试的存在,就无法消除它们。每次遇到这种情况,请确保使用一致的注释进行标记,例如:
鼓励团队中的每个人都这样做。一致的关键字可以让你轻松地在代码中找到所有不稳定测试,并快速了解它们的数量和位置。
2. 每个迭代周期修复一个不稳定测试
为你的团队采用以下规则:在每个迭代周期(Sprint)开始新任务或功能之前,至少修复一个不稳定测试。
把它想象成每周从你的杂物抽屉里清理出一件物品。随着时间的推移,抽屉(你的不稳定测试积压任务)最终会变空。这种做法对管理层来说很容易解释(“每个周期只修一个测试”),但累积起来却会有显著的差异。
有些团队更进一步,专门留出一天(如“不稳定测试星期五”)来系统地解决这些问题。如果你的项目有大量不稳定测试,这种方法特别有效。
3. 使用全新的测试数据
确保每个测试都从“干净的状态”开始:
- 隔离测试数据:为了防止冲突,为每个测试使用唯一的数据。Testcontainers 等工具允许你快速启动数据库和其他服务的临时容器。
- 设置与清理:在 JUnit 等框架中,使用
@BeforeEach来设置新数据,使用@AfterEach来清除数据:
4. 等待条件满足而非固定延时
避免使用硬编码延时,转而等待特定的事件或条件。针对各种流行的端到端测试框架:
- Selenium:使用
WebDriverWait等待元素出现或可点击:$ java - Cypress:利用内置命令(如
.should('be.visible'))来等待元素。 - Playwright:使用 Playwright 的自动重试断言,如
toHaveText和toBeVisible。它们会等待条件满足,如果未能在一定时间内满足则会失败。例如:$ node - WebdriverIO:与 Playwright 类似,内置断言会自动在可配置的超时时间内等待条件满足:
$ node
这种方法使测试对系统性能和负载的变化更具弹性。
5. 并行运行测试
并行执行可以加快测试速度并揭示隐藏的依赖关系:
- Gradle:使用
--parallel标志启用测试任务的并行执行。 - JUnit 5:将
junit.jupiter.execution.parallel.enabled设置为true以并行运行测试。 - WebdriverIO 和 Playwright:默认情况下,它们会并行运行不同文件的测试。你还可以配置 Playwright 以并行化文件内的测试。
如果测试在并行运行时失败,说明它们可能依赖于共享状态。
6. 暂时隔离不稳定测试
当一个不属于你当前更改的不稳定测试失败并阻碍进度时,将其隔离,以免它阻止你合并代码:
使用一致的前缀(例如 quarantine:)来轻松查找和跟踪这些测试。但是,不要让隔离的测试永远被忽略——请将修复它们作为优先级事项。
7. 分离端到端测试与集成测试
端到端和集成测试通常更慢,且更容易不稳定,因为它们涉及多个组件。评估每个测试是否处于正确的抽象层级:
- 集成测试:验证组件在与其他组件交互时的行为(例如代码与数据库之间),而不使用 UI。
- 单元测试:在隔离状态下验证单个函数或类的行为。
团队通常倾向于添加集成测试,因为这看起来更简单:测试数量更少、范围更广,能覆盖更多代码。然而,这种方法会导致更大、更慢且更难维护的测试套件。解决此问题的一个可行方法是重构复杂的多用途方法,使每个方法都有明确的单一目的。这简化了方法,也使它们更容易通过单元测试进行验证。例如,在一个项目中,我将一个运行了五分钟的庞大集成测试套件重构,使大部分逻辑由单元测试覆盖,最终将总运行时间缩短到了仅 11 秒。
构建可靠的测试套件:一种文化转变
消除不稳定测试不仅仅是一个技术挑战,它需要团队内部的文化转变:
- 教育团队:解释不稳定测试的影响和成本。分享示例,并鼓励每个人将不稳定测试作为重中之重。
- 跟踪不稳定测试:维护一个已知不稳定测试的列表或仪表板,包括它们的状态和负责人。在每日站会中提及它们,以免被遗忘。
- 设定明确目标:承诺在设定的时间内修复一定数量或比例的不稳定测试。
- 庆祝进展:认可并赞赏那些修复不稳定测试的成员。积极的反馈会激励团队持续改进测试套件。
结论
不稳定测试不仅仅是小小的烦恼。它们浪费宝贵的时间,破坏团队的信任,并拖慢开发进度。好消息是你不需要一次性解决所有问题。从这些想法中的一个开始:每个迭代周期修复一个测试,确保使用隔离数据,等待特定条件,并行运行测试,隔离问题测试,或用更小、更专注的测试替换庞大的端到端测试。仅仅采用其中一项,就是让你的测试套件更可靠迈出的重要一步。
把你的测试套件想象成那个杂物抽屉:如果你定期清理,它就会一直保持好用且易于管理。挑选一个经常引起麻烦的不稳定测试并优先修复它。那个小小的成功将激励你的团队,使你的测试过程变得更加顺畅。
欲了解更多详情,你可以观看我的演讲《在薄冰上测试:逐步消除测试的不确定性》,或者获取我的电子书《停止重运行,开始发布:消除不稳定测试的 7 个策略》的免费副本。