一套 90% 覆盖率的测试套件,完全可能是零价值的:改错一个判断条件,测试照样全绿;重构一次内部实现,三十个用例同时变红,其中没有一个对应真实缺陷。覆盖率衡量的是”代码被执行过”,它不衡量”错误能被发现”。
价值来自改动时的信心
给测试估值的一个近似公式是:
测试的价值 = 改动时提供的信心 − 维护这些测试的成本
分子取决于”如果我改坏了,会不会红”,分母取决于”我改对了,会不会也红”。多数团队只盯分子,忘了分母,于是测试越写越多、越写越脆,最后变成每次重构都要先修两小时测试的负担。
有一个可以自己做的检验:故意把某行判断取反、把 < 改成 <=,跑一遍测试看有没有失败。没失败,说明这行代码没有被真正断言,覆盖率里那 100% 是虚的。这种做法规模化之后就是变异测试,不必天天跑,在关键模块上跑一次能很快照出断言的空洞。
四种脆弱测试
脆弱测试的共同点是:代码行为没变,测试却红了。频繁出现会摧毁对套件的信任,最后没人愿意看红色。
依赖实现细节。断言内部调用次数或调用顺序,而不是最终可观测结果。
# 脆弱:绑定了实现步骤
def test_place_order():
repo = Mock()
svc = OrderService(repo)
svc.place_order(cart)
repo.save.assert_called_once() # 内部改成分批保存就红
repo.update_cache.assert_called_once() # 加了缓存就红
# 更稳:断言可观测状态
def test_place_order_persists_and_is_queryable():
repo = FakeOrderRepo() # 内存实现,不是 Mock
svc = OrderService(repo)
svc.place_order(cart)
assert repo.find(cart.order_id).status == "CREATED"
用内存假实现(fake)替代行为模拟(mock),往往能同时降低脆弱度并提高表达力。mock 适合验证”通知类”副作用(发了一条消息),不适合验证状态。
断言过度。对整个对象做深度相等,或者对整份 JSON 做快照。加一个与本次逻辑无关的字段,几十个快照全红。断言应该只覆盖本次用例关心的字段。
时间与随机依赖。用例里直接 datetime.now()、random、依赖当前时区、依赖字典遍历顺序、依赖测试执行顺序。症状是”偶发红”——最消耗信任的一种失败。
共享状态。静态变量、全局单例、多个用例复用同一个数据库且未清理。表现为单独跑绿、整包跑红,或者换个执行顺序就红。
| 脆弱类型 | 典型症状 | 修法 |
|---|---|---|
| 依赖实现细节 | 重构后大面积红,行为未变 | 断言可观测结果,用 fake 替代 mock |
| 断言过度 | 无关字段改动引发失败 | 只断言相关字段 |
| 时间/随机依赖 | 偶发失败 | 注入时钟、固定随机种子 |
| 共享状态 | 顺序相关、单独跑正常 | 每个用例独立初始化与清理 |
可测性设计
测试难写,通常不是测试的问题,是代码结构的问题。三处改动能显著提高可测性。
依赖注入。不要在函数内部 new 出 collaborators,从构造函数或参数传进来。这不是为了”面向接口编程”的教条,而是为了在测试里换掉那一个不可控的部分。
# 难测:时间、网络、随机都写死在函数里
def should_retry(err):
return time.time() - err.first_at < 300 and random.random() < 0.5
# 可测:把不确定输入变成参数
def should_retry(err, now, rand):
return now - err.first_at < 300 and rand < 0.5
纯函数化。把”决策”从”副作用”里剥出来:一个不碰 IO、不读时钟的纯函数负责算,外面一层薄壳负责调数据库、发消息、写日志。纯函数的测试不需要任何假对象,成本极低,而缺陷大多集中在决策部分。
def compute_discount(items, coupon): # 纯函数,覆盖所有分支
...
def apply_discount(order_id, coupon, db, clock): # 薄壳,只做编排
order = db.load(order_id)
order.discount = compute_discount(order.items, coupon)
db.save(order)
时钟抽象。把”现在几点”做成可注入的接口,测试里用固定时钟。同理适用于 ID 生成、随机数、配置读取。这类抽象几乎不改变生产代码复杂度,但能把一整类偶发失败彻底消掉。
可测性是有边界的:为了可测而把每个类都拆出接口,会让生产代码复杂度上升。经验上,只对”不可控或不确定的东西”做抽象——时间、随机、IO、外部服务。纯计算部分不需要。
测试金字塔的现实偏差
经典金字塔建议大量单元测试、少量集成测试、极少量端到端测试。这条建议成立的前提是:业务逻辑厚、集成点少。现实里很多服务恰恰相反——一个订单系统可能有大量逻辑,也可能主要是字段校验加转发,真实缺陷几乎都出在 SQL、序列化、配置、第三方协议上。
对后一类服务,把时间投在单元测试上收益很低,因为单测里的 mock 数据库不会告诉你 SQL 写错了。用真实数据库(容器化的独立实例)、真实序列化跑集成测试,能捕获的错误类型完全不同。
判断依据可以从缺陷统计来:回顾过去二十个线上问题,看有多少能被单元测试捕获、多少只能被集成测试捕获。比例会直接告诉你该在哪一层加投入。
折中方案是契约测试:消费者与提供者之间就接口约定做自动化校验,既不需要起完整链路,也比 mock 真实——mock 与真实服务的行为漂移是集成缺陷的常见来源,契约测试正是防这一点的。
该测什么
- 分支逻辑:条件判断、状态机的转移、循环的终止条件。这是缺陷密度最高的地方。
- 边界值:空集合、0、1、负数、上限、超出上限一位、超长字符串、空字符串、null。
- 历史 bug:每一个修过的线上缺陷都应该有一条回归用例。这类用例的性价比最高,因为它针对的是已经被证明会出错的地方。
- 错误路径:异常如何转换、失败时状态是否回滚、错误信息是否保留上下文。实际生产里错误路径的缺陷往往比正常路径多。
- 对外契约:公共 API 的参数校验、返回结构、错误码。这部分变化会影响调用方,值得锁住。
不该测什么
- getter / setter / 简单赋值:它们没有逻辑,测的是语言本身。
- 框架与标准库:”往 list 里加一个元素后 size 变成 1″这类用例只是在验证框架没坏。
- 私有实现细节:通过公有行为测试。直接测私有方法会让每次内部重构都变成测试维护工作。
- 配置与常量:断言”配置里的超时等于 30″没有意义,除非这个常量参与了某段计算逻辑。
- 第三方库的行为:默认第三方是对的。只有当你们的用法很微妙,或者这个库是集成风险点时,才写少量用例把约定固定下来。
判断一条用例是否该存在的操作式标准:问”删掉它,我会失去什么”。答不上来,通常意味着它只是在贡献覆盖率数字,同时贡献维护成本。测试套件和代码一样需要删除,长期不清理的套件会慢慢变成改不动的东西。
评论(1)
补充一个坑:如果中间断网,重试会重复提交,得自己做幂等。
请 登录 后参与评论。