测试与代码质量

优秀单元测试的条件:FIRST 原则 5 要点总结

明明写了大量测试代码,却没抓住真正的 Bug,而且每修复一个功能,测试就接连失败过吗?

4 分钟阅读
优秀单元测试的条件:FIRST 原则 5 要点总结 封面图

明明写了大量测试代码,却没抓住真正的 Bug,而且每修复一个功能,测试就接连失败过吗?

先说结论,优秀的单元测试不在于数量或覆盖率,而在于是否遵循“FIRST 原则”。FIRST 分别代表 Fast(快速)、Isolated(独立)、Repeatable(可重复)、Self-validating(自验证)和 Timely(及时)。

记住这五点,测试就不再是负担,而会成为可靠的安全网。今天结合我的经验,逐一说明。

FIRST 原则一览

FIRST 是 Robert Martin(Uncle Bob)在《Clean Code》一书中介绍后广为流传的原则。

每个字母都代表优秀单元测试的一项条件。

字母 含义 要点
F Fast 数百个在几秒内完成
I Isolated 测试之间互不干扰
R Repeatable 随时随地运行都得到相同结果
S Self-validating 自动判定通过或失败
T Timely 在适当时机编写

下面逐一拆解。


Fast:为什么必须这么快?

单元测试必须足够快。真的。

因为速度慢,开发者就不会运行它们。一次要花 3 分钟的测试套件,最后往往只会在提交前勉强运行。

每次修改代码时,都能毫无负担地运行数百次,测试才算发挥作用。

变慢最常见的原因是访问真实数据库、网络和文件系统。通常应使用测试替身(Mock、Stub)替换这些外部依赖。

可以将目标设为每个测试几毫秒,全部数百个测试在几秒内完成。

所有测试都变绿时的安心感,你懂的吧?
所有测试都变绿时的安心感,你懂的吧?

Isolated:测试不能互相干扰的原因

每个测试都应该独立,不能依赖其他测试的结果。

例如,测试 A 创建的数据被测试 B 使用时,A 一旦失败,B 也会跟着崩溃。只要执行顺序改变,结果也会不同。

因此,每个测试都应该自行准备所需数据,并在结束后彻底清理。

下面是每次测试前初始化状态的常见模式。

struct CalculatorTests {
    let calculator: Calculator

    init() {
        // 每次测试都会创建新实例 → 防止共享状态 suite 
        calculator = Calculator()
    }
}

这样无论哪个测试先运行,都不会互相影响。

在 Xcode 中使用 @Test 和 init() 编写的 Swift Testing 测试套件及通过结果界面
借助 init(),每个测试都从新实例开始,彼此保持独立

Repeatable & Self-validating:结果不能不稳定

先看 R,也就是可重复性。测试无论运行多少次、在哪种环境中运行,都应该得到相同结果。

今天通过、明天失败的测试称为 flaky 测试,是破坏信任的主要原因。

依赖当前时间或随机值很容易导致这类问题。如果必须处理时间,注入固定值进行控制会更安全。

S,即自验证,同样重要。测试必须自行判断通过还是失败。

把值打印到控制台后由人用眼睛确认,不算测试。应该使用 #expect 等断言自动判定结果。

优秀的测试只会显示两种结果:绿色或红色。如果让人思考“这样对吗?”,这个测试就已经失败了。


Timely:什么时候编写测试最合适?

最后的 T 代表及时性,也就是在适当时机编写测试。

在 TDD(Test-Driven Development,测试驱动开发)中,会先编写测试,再编写生产代码。即使不完全采用这种方式,关键也是在实现功能的相近时间一起编写测试。

如果功能完成几周后才想一次性补齐测试,代码往往已经固化成难以测试的结构。

不要把测试拖到以后,这就是 Timely 的核心。


总结

FIRST 与其说是需要背诵的规则,不如说是一份检查清单:当测试令人烦闷时,它能帮助你找出偏离之处。

速度慢吗?彼此耦合吗?结果不稳定吗?需要人工检查吗?是不是写得太晚?只要检查这五点,测试质量就会明显提升。

从今天开始,每写一个测试时都想一想 FIRST。它一定会成为可靠的伙伴。

延伸阅读