测试金字塔与投入分配
很多团队对测试的态度是「有时间就写,没时间就跳过」,于是测试就成了发布前的最后一根稻草。但质量不是测出来的,而是建进流程里的。要在有限的投入里获得最大的质量收益,第一步是搞清楚「不同层次的测试分别能拦住什么问题」。
这就是测试金字塔要回答的问题:底层是大量快速、稳定的单元测试,中层是适量的集成测试,顶层是少量端到端测试。倒金字塔(大量端到端、几乎没有单元测试)是反模式——执行慢、极不稳定,一旦失败很难定位。
单元测试快而脆(易随重构调整),端到端测试真但慢且脆(易受环境影响)。金字塔的意义,是把「反馈速度」和「真实性」按合理比例搭配起来。
金字塔没有固定数字,但有固定的方向:越往下越多,越往上越少。如果你的测试大部分是端到端,那质量门禁就会变成发布前的一场豪赌。
- 测试金字塔单元 / 集成 / 端到端按数量递减排列,越往下越快越稳定。
- 单元测试针对最小可测单元,快速验证逻辑正确性。
- 测试替身桩、模拟、假对象,用来隔离外部依赖。
- 覆盖率是「有没有测到」的参考,不是质量目标本身。
- 质量门禁在持续集成中用自动化规则守住合并前的质量底线。
单元测试的写法与断言
单元测试针对的是「一个函数或一个类」这样的最小单元,运行时间应在毫秒级。它有三个标准:快(毫秒级)、独立(不依赖执行顺序与外部环境)、可重复(任何机器上结果一致)。
# 用 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% |
| 分支覆盖率 | 每个条件分支是否都被走过 | 比行覆盖率更接近真实覆盖 |
| 缺陷逃逸率 | 上线后才发现的问题占比 | 衡量质量门禁有效性的关键指标 |
| 测试执行时长 | 完整跑一遍测试需要多久 | 影响反馈速度,宜控制在十分钟内 |
| 构建成功率 | 持续集成构建通过的比例 | 过低说明门禁过严或测试不稳 |
一条重要提醒:覆盖率可以被「刷」。写一堆不校验任何断言的测试,覆盖率轻松上九十,但一个缺陷也拦不住。所以覆盖率要配合断言质量一起看——测试的价值在于断言,而不是执行。
CI 流水线与质量门禁
测试写得再好,如果没人跑,也守不住质量。持续集成的作用就是把测试与检查自动化地拼成一条流水线,在代码合并前自动执行。
# .github/workflows/ci.yml(节选)
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install -r requirements-lock.txt
- run: ruff check . # 静态检查
- run: mypy src # 类型检查
- run: pytest --cov=src --cov-fail-under=80 # 覆盖率门禁
- run: python -m build # 构建产物
一条健康的流水线通常按「由快到慢」排序:静态检查、单元测试、集成测试、构建。这样最便宜的问题会最先被发现,失败时能立刻定位。质量门禁的规则要明确且可解释——「覆盖率不低于 80%」「关键模块改动必须有两人评审」比一句「注意质量」有用得多。
缺陷预防与团队质量文化
测试能发现缺陷,但更高级的做法是让缺陷不容易产生。这需要把质量从「测试岗位的职责」变成「团队的习惯」:
- 代码评审:把评审重点放在边界条件、错误处理与可读性上,而不是风格。
- 缺陷复盘:每逃逸一个线上缺陷,追问「哪一层门禁本该拦住它」,并把补丁加进去。
- 测试可维护:测试代码与生产代码同等对待,避免「改一次业务要改十处测试」。
- 质量内建:需求评审时就讨论验收标准,设计阶段就考虑可测性,而不是留到最后补测。
最后回到一开始的判断:测试的价值不在于「测了多少」,而在于让团队多了多少信心去改代码。一套可靠的测试,是持续重构与快速迭代的前提,也是工程质量最实在的底座。