你有 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 向你展示生产环境里什么在崩,附带堆栈信息和上下文——这些是你的单元测试永远给不了的。