Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2. 测试工程: 测试金字塔 / 单元/集成/契约/E2E / mock / flaky 治理

TL;DR

测试不是"写几个 assert",而是一套控制回归风险的系统。这一章把测试从"会写"提升到"会设计":知道测什么、在哪一层测、用什么替身、怎么让测试不 flaky、怎么用覆盖率做决策(而不是当指标)。

读完应能:

  1. 说清测试金字塔为什么是对的,以及它不适用时的例外。
  2. 区分单元 / 集成 / 契约 / E2E 四种测试的职责与成本。
  3. 正确使用 mock/stub/fake/spy,知道"过度 mock"的病。
  4. 诊断并治理 flaky 测试。
  5. 用测试覆盖率的正确用法辅助重构决策。

一、测试金字塔

1.1 分层与成本

        / E2E \           数量少(几十)
       /  契约  \         数量中(几百)
      /   集成    \       数量多(几千)
     /    单元      \     数量最多(几万)
    /________________\
层级速度成本稳定性隔离度测什么
单元毫秒-秒一个函数/类
集成秒-分模块间、DB/网络
契约服务间接口
E2E分-时整条用户路径

原则:越往上数量越少。金字塔的几何形状就是最优成本结构——把多数测试放在快而稳定的底部。

1.2 反模式

  • 倒金字塔:全 E2E,慢、flaky、难定位 → 是测试最贵且最没用的形态。
  • 冰淇淋筒:上面 E2E 多、中间少、底部 mock 一堆 → 单元测试全是 mock,集成空缺。
  • 无测试:靠手动 QA → 回归全靠人肉,无法持续发布。

1.3 覆盖策略(Test Pyramid 的落地)

每个功能改动的理想测试分布(Google 实践):

单元: 70-80%   — 覆盖所有分支/错误路径(快、密集)
集成: 15-20%   — 覆盖模块组合、真实 DB/依赖
E2E : 2-5%     — 关键 happy path 冒烟
契约: 按服务边界补 — 微服务间接口防破坏

二、四种测试的职责

2.1 单元测试(Unit)

  • 一个函数/类的单一行为,不跨模块、不碰 IO。
  • 目标:逻辑正确、边界处理、错误路径、性能敏感函数。
  • 关键:测行为不测实现(改实现不该破坏测试)。
// Go 单元测试示例
func TestCalculateTotal(t *testing.T) {
    items := []Item{{Price: 10, Qty: 2}, {Price: 5, Qty: 1}}
    got := CalculateTotal(items)
    if got != 25 {
        t.Errorf("got %v want 25", got)
    }
}
# Python 单元测试
def test_calculate_total():
    assert calculate_total([("a", 10, 2), ("b", 5, 1)]) == 25

def test_empty_cart():
    assert calculate_total([]) == 0

2.2 集成测试(Integration)

  • 模块之间的真实协作,包括真实 DB、真实网络、真实文件系统。
  • 捕获的问题:API 签名不匹配、类型/编码转换、事务边界、并发竞态。
  • 手段:真实依赖(Testcontainers 起真实 DB/redis)+ 少用 mock。
# 用 Testcontainers 起真实 Postgres
def test_user_persistence():
    with Testcontainers("postgres:16").running() as pg:
        repo = UserRepo(pg.get_connection_url())
        repo.create(User("alice"))
        assert repo.find("alice").name == "alice"

2.3 契约测试(Contract)

  • 微服务间:消费方(consumer)与提供方(provider)各持一份契约,独立验证。
  • Pact 思路:consumer 生成契约(它期望的请求/响应),provider 验证自己满足契约。
  • 价值:两端独立部署也不会悄悄破坏接口——比 E2E 快得多,且精准定位是哪端破坏。
消费者 (consumer)                     提供者 (provider)
   └─ 期望: GET /user/1 → {id:1}       └─ 验证: 我确实返回 {id:1}
        生成契约文件 pact.json ──────→  provider 测试跑契约

2.4 E2E 测试

  • 模拟真实用户完整路径:登录 → 操作 → 结果,跑在完整部署上(浏览器/真实 API)。
  • 工具:Playwright / Cypress / Selenium。
  • 定位:冒烟(关键路径别挂)+ 少量核心流程,不是全覆盖。
// Playwright E2E
import { test, expect } from '@playwright/test';

test('用户能下单', async ({ page }) => {
  await page.goto('/');
  await page.getByText('添加购物车').click();
  await page.getByRole('button', { name: '结算' }).click();
  await expect(page).toHaveURL(/\/checkout/);
  await expect(page.getByText('订单成功')).toBeVisible();
});

三、测试替身(Test Double):mock / stub / fake / spy

3.1 四种替身

替身作用何时用
Stub返回预设数据,不含逻辑提供依赖返回值
Mock验证"方法被调用且参数正确"验证交互行为
Fake简化实现(内存版 DB)替代重依赖
Spy记录调用供断言检查是否被调用

3.2 过度 mock 的问题

# ❌ 过度 mock:测试的是 mock 自己,不是代码
def test_order(mocker):
    db = mocker.patch("app.db.query")      # mock 了 DB
    cache = mocker.patch("app.cache.get")  # mock 了缓存
    notify = mocker.patch("app.notify")    # mock 了通知
    # ... 三个全 mock 后,测的其实是胶水,业务逻辑没测到

# ✅ 更真实:保留逻辑层,mock 只在 IO 边界
def test_order_flow(test_db):
    repo = UserRepo(test_db)              # 真实 DB(fakeredis / testcontainers)
    result = place_order(repo, cart)
    assert result.status == "paid"

判断标准:如果 mock 掉的东西越多、断言越细,测试就越脆。只在"边界 IO"(外部服务、时间、随机)处 mock,业务逻辑用真实实现。

3.3 依赖注入让测试容易

# 设计上支持替换依赖
def send_notification(sender: Notifier, msg: str):   # 传入接口
    sender.push(msg)

# 测试时传 Fake
class FakeNotifier(Notifier):
    def __init__(self): self.sent = []
    def push(self, msg): self.sent.append(msg)

def test_send():
    fake = FakeNotifier()
    send_notification(fake, "hi")
    assert fake.sent == ["hi"]

tip

"依赖注入 + 接口"比"全局 mock 补丁"更干净。可测性是最被低估的架构属性——代码可测,通常意味着解耦良好。


四、Flaky 测试治理

4.1 什么是 flaky

同一份代码,跑两次结果不同——一次过一次挂。Flaky 是持续交付的最大敌人:它让 CI 信号不可信,开发开始忽略红

4.2 常见根因

根因例子解法
时序/竞态断言在异步回调前执行轮询等待而非 sleep;用 eventually
随机性测试依赖随机数/时间注入确定性种子 / fake clock
共享状态测试间共享 DB/静态变量每测试独立隔离(truncate/txn rollback)
环境依赖依赖网络/外部服务契约测试替代;本地 stub
顺序依赖测试依赖前一个测试留下的状态每测试自含 setup/teardown

4.3 反例:sleep 是罪魁

# ❌ sleep 猜测时序
response = api.start_async_job()
time.sleep(5)                     # 5 秒后应该完成了
assert response.done

# ✅ 显式等待直到条件满足
def wait_until(predicate, timeout=10):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if predicate(): return True
        time.sleep(0.1)
    raise TimeoutError()

assert wait_until(lambda: api.job_status(job_id).done)

4.4 治理流程

1. 复现:用 --count=100 反复跑(pytest: --count, go test: -count=100)
2. 隔离:找到 flaky 的最小复现
3. 定位:加日志 / 用 go test -race / pytest-timeout
4. 修复:消除根因(上面表)
5. 防再发:把修复用例留成回归;加 CI 的 retry 是最后手段,不是解法

五、覆盖率:正确使用

5.1 覆盖率不是目标

  • 覆盖率高 ≠ 测试好:可以 100% 覆盖而全是无效断言。
  • 但覆盖率低几乎总是坏(除非是新代码区)。
  • 正确用法:辅助发现"没测的路径",而不是 KPI。

5.2 有用的度量

度量含义用途
Line coverage行覆盖基本体检
Branch coverage分支覆盖(if/else/switch)比行覆盖更准
Mutation testing改代码看测试会不会红验证测试质量
Diff coverage只统计这次改动覆盖重构/新功能最该看

note

Diff coverage(新增/改动代码的覆盖率)比总量覆盖更有意义。它回答"我这次改动有没有测到"。很多团队用工具(如 coveralls/sonar)把 diff coverage 卡在阈值(如 80%),效果远比总量阈值好。

5.3 覆盖率的正确决策流

写代码 → 写测试 → 看 diff coverage
  ↓ 低分支覆盖
  → 补分支:错误路径 / 边界 / 空值
  ↓ 高覆盖但测试很脆
  → 检查:是否在测实现细节?是否需要重构测试?

六、测试策略落地(团队层面)

6.1 CI 里的测试分层执行

PR 阶段(快,必跑):
  - 单元测试(毫秒级,全部跑)
  - Lint + 类型检查
  - 变更文件的 diff coverage 检查

合并后(慢,异步):
  - 集成测试(Testcontainers)
  - 契约测试(Pact)
  - E2E 冒烟(部署到 staging)

6.2 测试金字塔的健康检查

  • 单元测试跑 < 1 分钟 → 正常;> 5 分钟 → 该拆测试或并行。
  • E2E 占总量 > 5% → 危险,该下移。
  • 大量测试需要 mock 数据库 → 架构耦合问题。
  • 测试常"修一下就好"(改断言不测逻辑)→ 测试在测实现,不是行为。

6.3 先写测试 vs 后写测试

  • TDD(先写失败测试):适合纯逻辑、算法、规则引擎。
  • 后写测试:适合探索性代码、UI、集成层。
  • 关键不是"谁先",而是每段代码离开手前都要有测试覆盖

七、结束 + 速查表

tip

一页快速唤回:

  • 测试金字塔:单元(多快稳)> 集成 > 契约 > E2E(少慢脆)。
  • 单元:测行为不测实现;一个函数一类行为。
  • 集成:真实 DB/依赖,Testcontainers 起真实服务。
  • 契约:consumer/provider 各持契约,微服务防接口破坏。
  • E2E:关键路径冒烟,不是全覆盖。
  • 替身:Stub(返回数据)/ Mock(验证交互)/ Fake(简化实现)/ Spy(记录调用);只在 IO 边界 mock。
  • 可测性 = 依赖注入 + 接口,是最好的架构属性。
  • Flaky 根因:时序/随机/共享状态/环境/顺序依赖;用 wait_until 不用 sleep。
  • 覆盖率:diff coverage 比总量有意义;测行为不测行数。
  • CI 分层:PR 跑快测,合并跑慢测。

下一篇: 3. CI/CD 与发布工程: 管线 / 镜像 / 蓝绿金丝雀 / IaC.