测试金字塔与投入分配

很多团队对测试的态度是「有时间就写,没时间就跳过」,于是测试就成了发布前的最后一根稻草。但质量不是测出来的,而是建进流程里的。要在有限的投入里获得最大的质量收益,第一步是搞清楚「不同层次的测试分别能拦住什么问题」。

这就是测试金字塔要回答的问题:底层是大量快速、稳定的单元测试,中层是适量的集成测试,顶层是少量端到端测试。倒金字塔(大量端到端、几乎没有单元测试)是反模式——执行慢、极不稳定,一旦失败很难定位。

单元测试快而脆(易随重构调整),端到端测试真但慢且脆(易受环境影响)。金字塔的意义,是把「反馈速度」和「真实性」按合理比例搭配起来。

金字塔没有固定数字,但有固定的方向:越往下越多,越往上越少。如果你的测试大部分是端到端,那质量门禁就会变成发布前的一场豪赌。

本文核心知识点
  • 测试金字塔单元 / 集成 / 端到端按数量递减排列,越往下越快越稳定。
  • 单元测试针对最小可测单元,快速验证逻辑正确性。
  • 测试替身桩、模拟、假对象,用来隔离外部依赖。
  • 覆盖率是「有没有测到」的参考,不是质量目标本身。
  • 质量门禁在持续集成中用自动化规则守住合并前的质量底线。
软件测试示意:检查流水线上的圆点节点、盾牌与同心圆环组成的蓝色扁平插画
质量门禁像流水线上的检查站:让问题在进入下一道工序前就被拦下。

单元测试的写法与断言

单元测试针对的是「一个函数或一个类」这样的最小单元,运行时间应在毫秒级。它有三个标准:快(毫秒级)、独立(不依赖执行顺序与外部环境)、可重复(任何机器上结果一致)。

# 用 pytest 写一个订单折扣的单元测试
import pytest
from domain.pricing import apply_discount

def test_no_discount_for_small_order():
    assert apply_discount(100, member=False) == 100

def test_member_gets_ten_percent_off():
    assert apply_discount(100, member=True) == 90

@pytest.mark.parametrize("amount,expected", [
    (0, 0),            # 边界:金额为 0
    (199, 199),        # 边界:低于折扣门槛
    (200, 180),        # 恰好等于门槛
])
def test_discount_boundaries(amount, expected):
    assert apply_discount(amount, member=True) == expected

def test_negative_amount_raises():
    with pytest.raises(ValueError):
        apply_discount(-1, member=True)

写单元测试有个常见误区:一个测试函数里塞进太多断言。一旦失败,你无法一眼看出是哪条逻辑坏了。更好的做法是一个测试只验证一个行为,测试名直接说明「在什么条件下、期望什么结果」。

集成测试与契约测试

单元测试证明了「每个零件都对」,集成测试要证明「零件装在一起也对」。它覆盖的是模块之间的边界:数据库读写、消息投递、HTTP 接口调用等。

当系统被拆成多个服务时,服务之间的接口契约就成了新的风险点。契约测试的思路是:由消费方定义它期望的接口行为,提供方据此验证自己的实现——任何一方改动导致契约被破坏,测试立刻失败,而不是等到联调时才发现。

集成测试的代价高于单元测试(更慢、更需要环境),因此数量要克制:只测那些单元测试覆盖不到的边界,而不是把业务逻辑在集成层再测一遍。重复覆盖只会拉长反馈时间,却不会带来额外的信心。

测试替身:桩、模拟与假对象

当被测代码依赖数据库、第三方接口或时间时,就需要用测试替身把外部依赖替换掉。替身分三类:

# 1) 桩(Stub):返回预设值,只为让被测代码跑通
class StubRateService:
    def get_rate(self, currency): return 7.0     # 固定汇率

# 2) 模拟(Mock):断言「是否被调用、调用了几次」
from unittest.mock import Mock
notifier = Mock()
place_order(order, notifier)
notifier.send.assert_called_once_with(order.id)

# 3) 假对象(Fake):有真实逻辑但实现更轻,如内存版仓储
class FakeOrderRepo:
    def __init__(self): self.data = {}
    def save(self, o): self.data[o.id] = o
    def get(self, oid): return self.data.get(oid)

选择替身的经验法则是:桩管输入,模拟管交互,假对象管状态。能不用模拟就不用——过度使用模拟会让测试与实现细节强耦合,重构时测试先崩,团队就会开始怀疑测试的价值。

覆盖率与质量指标

覆盖率回答的是「有多少代码被测试执行到了」,它是一个有用的发现盲区的工具,但绝不能当成质量目标。

指标含义使用建议
行覆盖率被执行到的代码行占比看趋势与盲区,不追求 100%
分支覆盖率每个条件分支是否都被走过比行覆盖率更接近真实覆盖
缺陷逃逸率上线后才发现的问题占比衡量质量门禁有效性的关键指标
测试执行时长完整跑一遍测试需要多久影响反馈速度,宜控制在十分钟内
构建成功率持续集成构建通过的比例过低说明门禁过严或测试不稳

一条重要提醒:覆盖率可以被「刷」。写一堆不校验任何断言的测试,覆盖率轻松上九十,但一个缺陷也拦不住。所以覆盖率要配合断言质量一起看——测试的价值在于断言,而不是执行。