你有 90% 的代码覆盖率,却仍在凌晨两点被告警吵醒。

单元测试通过了。CI 是绿的。bug 还是溜进了生产环境。覆盖率没有撒谎,但它也没有说出真相。它衡量的是哪些行被执行了,而不是哪些行为真正被验证了。

大多数团队都是在吃过苦头后才明白这一点。他们写了成百上千个单元测试,看着覆盖率徽章变绿,就以为堡垒固若金汤。堡垒有墙,只是没有屋顶。

单元测试只测你能想象到会出错的地方

单元测试验证的是你对代码的假设。问题在于,bug 根本不关心你的假设。

来看一个简单的定价函数:

def calculate_total(items, tax_rate):
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    tax = subtotal * tax_rate
    return round(subtotal + tax, 2)

一套典型的单元测试看起来挺扎实:

def test_calculate_total_with_tax():
    items = [{"price": 10.00, "quantity": 2}]
    assert calculate_total(items, 0.08) == 21.60

def test_calculate_total_empty_cart():
    assert calculate_total([], 0.08) == 0.00

两个测试都通过了。覆盖率 100%。函数上线了。

然后一位日本顾客买了三件单价 ¥100 的商品,税率 0.10,预期总价 ¥330,函数返回 ¥330.00,没问题。

一位瑞士顾客买了一件 CHF 12.35 的商品,VAT 7.7%,预期 CHF 13.30,实际也是 CHF 13.30,仍然没问题。

然后一位顾客在俄勒冈州买了两件 $0.01 的商品,那里税率是 0.0,预期 $0.02,实际 $0.02,通过。

bug 出现在一位顾客所在的司法辖区税率是 None(因为税务服务对一个不认识的邮编返回了 null),结账时函数执行 subtotal * None,抛出了 TypeError。你的单元测试从来没把 None 当作税率传进去,因为你假设它永远是个 float。

这就是根本性的局限。单元测试跑的是你想到了的路径。bug 藏在你没想到的路径里。

单元测试够不到的四个地方

集成边界

单元测试把外部依赖替换成了 mock。mock 很客气,你让它干嘛它就干嘛。真实的 API 可没那么客气。

你的 mock 数据库几毫秒就返回行。生产环境要几秒才返回,或者超时,或者因为读副本延迟返回了重复的行——而你根本不知道有这个延迟。

你的 mock HTTP 客户端返回干净的 JSON。真实的服务在周二返回 200 和一个空 body。

mock 测试的是你的代码对你假设的其他系统的表现。生产测试的是你的代码对现实的表现。这是两套不同的测试套件,通过率也各不相同。

有状态和时序相关的 bug

单元测试是隔离运行的。每个测试拿到一份全新的状态。生产环境是一个长期运行的进程,状态会累积、泄漏、自我交叉影响。

内存缓存在高负载下驱逐条目。连接池在 10000 个请求后耗尽。时间戳比较在测试跨夏令时边界时失败。这些 bug 需要时间、流量或者特定序列才能暴露。单元测试什么都不具备。

并发和竞态条件

两个用户同时更新同一条记录。一个请求读到了余额,另一个扣了款,第一个又把旧的余额写回去了。钱消失了。你的单元测试在单线程里顺序执行,根本抓不到这个。

你可以给单个锁原语写单元测试。但你没法写一个单元测试来证明整个系统没有竞态。状态空间太大,时机太不确定。

环境本身

你的测试跑在 Ubuntu 22.04、Python 3.11、4GB 内存、没有防火墙规则的环境里。生产环境跑在 Alpine Linux、Python 3.11、512MB 内存、安全组会在 60 秒后掐掉空闲 TCP 连接的环境里。

socket 模块表现不同。mmap 上限更低。locale 设置导致 strftime 格式化出来的日期超出了你的解析器的预期。这些不是代码 bug,是上下文 bug。单元测试没有上下文。

为什么覆盖率百分比会误导你

覆盖率工具衡量的是行被执行了,而不是断言的质量。一个测试可以把函数的每一行都执行一遍,却几乎什么都没验证。

def test_poor_coverage_quality():
    result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
    # Executed 100% of lines. Verified almost nothing.
    assert result is not None

这个测试给你 100% 行覆盖率,零信心。很多团队优化这个指标,因为它好衡量。信心很难衡量。于是他们拿覆盖率代替,希望两者相关。

它们不相关。

该测什么(或者额外测什么)

这不是反对单元测试的论据。单元测试快、确定性强,非常适合验证算法逻辑。它们只是不完整。

以下是填补漏洞的办法,同时不会把你的 CI 管道变成 45 分钟的累赘。

在系统边界测试,而不只是内部

与其 mock 数据库,不如写测试去连真实的测试数据库。这些测试更慢,所以有选择地跑。但它们能抓住 ORM 查询和查询优化器实际行为之间的不匹配。

与其 mock HTTP 客户端,不如把下游服务在容器里跑起来。这能抓住 schema drift、超时行为,以及只有在真实连接失败时才会触发的重试逻辑。

为外部服务添加 contract tests

如果你没法在 CI 里跑真实依赖,就用 contract tests。它们验证你的消费者预期和提供方的实际 API schema 是否一致。

像 Pact 这样的工具会记录你的服务和依赖之间的交互。如果提供方改了字段类型或者下线了一个端点,contract test 会在代码部署前就挂掉。它不如集成测试好,但比祈祷你的 mock 准确得多。

用 property-based testing 抓边界

property-based testing 工具比如 Hypothesis(Python)或 fast-check(JavaScript)会生成成千上万组随机输入,验证你的 invariants 是否成立。

from hypothesis import given, strategies as st

@given(
    st.lists(st.fixed_dictionaries({
        "price": st.decimals(min_value=0, max_value=10000, places=2),
        "quantity": st.integers(min_value=0, max_value=1000)
    })),
    st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
    if tax_rate is None:
        with pytest.raises(TypeError):
            calculate_total(items, tax_rate)
        return

    result = calculate_total(items, tax_rate)
    assert result >= 0
    assert result == round(result, 2)

这个测试会在你没想到要写 None 税率的情况下就抓住那个 bug。它会生成你永远不会考虑的输入:空列表、巨型列表、零价格、最大精度的 decimals。它在你还没想象到边界的时候,就找到了你逻辑的边界。

像对待测试环境一样监控生产

最诚实的测试套件就是生产流量。如果你没法在 bug 上线前抓住它,那就争取在它造成伤害前抓住它。

用 feature flags 先把变更推给 1% 的用户。盯着错误率、延迟分位数和业务指标。单元测试告诉你代码在隔离环境中是否按预期运行。生产监控告诉你代码在现实中是否按预期运行。

对异常设置告警,而不仅仅是硬故障。部署后 500 错误上升 5% 往往就是竞态条件或资源泄漏开始的唯一信号。单元测试永远给不了你这些信息。

诚实的权衡

单元测试便宜、快、适合开发者快速反馈。集成测试贵、慢、适合抓住真正重要的 bug。

两者都需要。陷阱在于以为 100% 单元测试覆盖率就可以跳过其他一切。它只意味着你把简单的部分测得很彻底。难的部分,那些半夜把你叫醒的部分,住在你测试没在看的地方。

从单元测试开始,测逻辑和算法。在每个系统边界加集成测试。用 property-based testing 找到你没想到的输入。监控生产,抓住所有测试都漏掉的东西。

覆盖率是个虚荣指标。唯一重要的指标是你能不能一觉睡到天亮。


如果你想抓住测试漏掉的 bug,先从看你的错误数据开始。Sentry 向你展示生产环境里什么在崩,附带堆栈信息和上下文——这些是你的单元测试永远给不了的。