第 18 章 测试体系
副题:测试金字塔——"改坏了怎么办"从口头担忧变成真金白银
第 17 章交易系统上线了,五道防线立起来了。但一个新的问题立刻出现:**这些机制怎么证明它们是对的?**状态机的转移表怎么测?并发场景怎么复现?回调乱序、重复请求、对账差异——这些"防线的防线",靠什么守住?本章回答这个问题:测试体系——怎么证明"业务正确"不是一句口号。
分工进行中:性能管"快"、交易管"对"、本章管"稳"的第一件——怎么证明"对"在每次改动后仍然成立
本章要建立的认知
- 测试的第一问是"回归":改坏了怎么办——测试存在的第一理由,是"改了之后还能跑";
- 测试金字塔:单元/集成/E2E 三层,越往下越多越快越便宜——投入按金字塔分配,不是平均用力;
- 测试是对业务正确的承诺:之前的每一个承诺都有一批对应的测试。
18.1 改坏了怎么办
阶段 3 的第一个"改坏了"事故,来得很快。
之前支付回调处理里发现一个 bug(金额校验顺序问题),修它需要改一处逻辑。自测通过——回调的用例走了一遍,没问题。上线。
第二天,对账抓到异常:下单接口失败率涨了 10 倍。 回滚,查原因:修回调代码时,"顺手"动了一个两个函数共用的工具函数——下单流程也调用了它,被一起改挂了。
这个事故不新鲜:改 A 崩 B。只是这次发生在交易系统里,代价是真金白银:10% 的下单失败,几百笔订单没建成,几十笔用户付了钱但订单没落库。对账兜底接住了,但客服电话已经响了。
完整链条: 改回调 → 共用函数被连带改挂 → 下单报错 → 对账第二天抓到差异 → 人工排查发现失败率飙升 → 回滚。
复盘这个问题:为什么没早点发现?
改完只验证了"改的那个功能"(回调处理),没验证"没改的功能"(下单流程)。而"没改的功能"才是大多数。
这就是测试的第一课:测试存在的第一理由,是"改完之后,还能跑",其次才是"证明代码是对的"。 这个"还能跑"的验证,叫回归测试(regression testing)——回归=回到过去,确认没把过去弄坏。
为什么手工验证不够?阶段 1 不是"点一点就行"吗?
两个硬伤:
- 记不住:阶段 3 接口 12 个以上、状态机几十条转移、防线五道,人脑记不住全部"应该怎样"
- 重复劳动:每次改动都要把同样路径点一遍,点到第十次就会"跳着点"——跳过的正好是出问题的那条
测试的本质,是把"每次改动都验证一遍"从人力变成代码。 人力会累会跳,代码不会。
那为什么阶段 1 不建这套体系?
复杂度预算(第 4 章)。阶段 1 接口少、规则少、改错了代价低——内容错了改回来就行,手工验证够用。阶段 3 接口多了、规则密了、改错要赔钱了——测试体系是被业务逼出来的,不是提前设计的。
业务越重,回归测试的需求越硬。阶段 1 的"改坏了"是口头担忧,阶段 3 的"改坏了"是真金白银。
事故处置三个动作,把事故变成养料:
- 回滚:先恢复线上
- 修 bug:改回共用函数
- 补测试:给"下单流程正常"写一条测试——让 bug 先红再绿,证明修复有效且不再复发
每一个线上事故,都应该变成一条新测试。 事故是"测试漏掉的承诺",补测试就是把这个承诺补回去。
补测试的时机:修复上线后立刻补,不要等"有空再说"。 事故的记忆是测试的保质期——趁新鲜补回去,紧迫感一消失就永远补不上了。
最后分清回归的两个层面:
- 功能回归:功能行为没变
- 性能回归:速度没变慢
"改了还能跑"包含两层:还能跑对,还能跑快。
18.2 测试金字塔:为什么是金字塔
写什么样的测试、写多少呢? 测试界有一个被反复验证的结构:测试金字塔。
三层,各司其职:
- 单元测试:测一个函数、一个类的内部规则——业务层校验、状态机转移、金额计算。不碰数据库、不碰网络、不碰 HTTP。毫秒级,一次能跑几千个,挂了立刻知道是哪行代码。
- 集成测试:测多个组件合起来能不能干活——服务层 + 真实数据库、接口层 + 契约验证。秒级,能发现层与层配合时才会暴露的问题。
- E2E 测试:测完整旅程——从 HTTP 请求到数据库到响应,模拟真实用户。分钟级,要起整套系统,挂了最难定位(可能是前端、后端、数据库任何一处)。
为什么是金字塔,不是三层平均?
三个理由,都从成本和速度里长出来:
第一,速度决定反馈。 单元测试毫秒级,改完立刻跑,开发时随时用;E2E 分钟级,跑一次要等,只能上线前用。反馈越快,用得越频繁,所以单元测试应该最多。
第二,成本决定数量。 E2E 要起整个系统,每加一个都贵,只能精挑细选;单元测试几乎零成本,可以大量写。
第三,定位决定层级。 单元挂了立刻知道是哪行代码;E2E 挂了要从头排查。问题在越底层被发现,排查成本越低。
常见新手错误是倒过来——只写 E2E,慢到不想跑;或者只写单元,漏掉集成问题。金字塔的比例本身就是决策。
金字塔是 18.1 事故的答案。
那场"改 A 崩 B"的事故:回调函数有单元测试吗?有,它自己的规则测了。但"共用函数被改挂"是下游影响,单元测试看不见——能挡住它的是集成测试或 E2E(比如一条"下单流程正常"的用例,改动后跑一遍就红了)。
所以金字塔是事故教出来的形状。
三层与前面章节的对应:
- 单元测试 → 测第 12 章的业务层(规则的家)
- 集成测试 → 测第 12 章的链路(中间件 + 控制器 + 服务 + 数据库)
- E2E 测试 → 测第 15 章的完整旅程
问题在哪层发现,就在哪层写测试:
| 问题类型 | 最早能在哪层发现 | 该写在哪层 |
|---|---|---|
| 业务规则错(校验、计算、状态) | 单元测试 | 单元 |
| 契约不一致(字段、状态码) | 集成测试 | 集成 |
| 权限漏洞(401/403) | 集成测试 | 集成 |
| 链路不通(各自能跑,合起来挂) | E2E | E2E |
自动测试不是万能的。
自动化管"每次都测"——已知路径、回归验证。 手动测试管"没想到的"——探索性测试,人随机乱点,机器想不到的路径它想得到。
两者是分工:自动化覆盖已知,手动探索未知。
测试与代码评审:
评审时看什么?看测试有没有覆盖承诺。 新功能的验收标准变成测试了吗?修 bug 时复现的测试补了吗?测试是评审的检查单——代码可以看不懂,测试覆盖不全一眼就能看出来。
测试代码怎么组织?
目录按金字塔分层:
tests/unit/ ← 单元测试
tests/integration/ ← 集成测试
tests/e2e/ ← E2E 测试
命名对齐被测模块。测试的目录结构,就是金字塔的物理形态。
18.3 底座:单元测试——测业务规则
第 12 章说过:分层让"单独测一层"成为可能。单元测试测的就是业务层——直接调用 service 函数,传参数、看返回,不碰 HTTP、不连数据库:
// 测业务规则:标题长度校验
it('内容标题超过 100 字应拒绝', () => {
const result = contentService.createContent(1, {
title: 'x'.repeat(101),
body: 'ok'
});
expect(result.error.code).toBe('INVALID_TITLE');
});
// 测业务规则:正常创建
it('内容合法应创建成功', () => {
const result = contentService.createContent(1, {
title: '相机',
body: '9 成新'
});
expect(result.data.id).toBeDefined();
});
单元测试测的是"规则"——校验规则、业务规则、计算规则。再看几个最典型的例子:
// 金额计算:第 17 章"分"存储铁律的测试
it('金额加法不丢精度', () => {
expect(addFen(100, 99)).toBe(199); // 1元 + 0.99元 = 1.99元,整数加法
});
// 权限规则:第 13 章"谁能删"
it('非作者删除内容应拒绝', () => {
const result = contentService.deleteContent(2, 10); // 用户2删内容10(作者是1)
expect(result.error.code).toBe('FORBIDDEN');
});
// 状态机:第 17 章转移表的每一条
it('CANCELED 收到支付成功回调必须拒绝', () => {
expect(() => transit({ status: 'CANCELED' }, 'pay_success')).toThrow();
});
it('PENDING 支付成功转入 PAID', () => {
expect(transit({ status: 'PENDING' }, 'pay_success')).toBe('PAID');
});
第 17 章对账抓到的 1 分钱事故,用单元测试最容易预防——金额计算的每一行都该有测试。对账是事后抓,单元测试是事前挡。
为什么单元测试是金字塔底座?三个优点决定了它的位置:
快——毫秒级,一次跑几千个。改完代码立刻全量跑一遍,这就是"回归"的日常形态:几千条规则在几秒内替你确认"没改坏"。
准——挂了立刻知道是哪个函数、哪条规则,不用层层排查。
稳——不依赖环境,不连数据库、不连网络,任何机器上跑结果都一样。
单元测试有盲区
它不知道"合起来"对不对——契约字段名对不对、权限中间件挂没挂、事务回滚真不真。这正是 18.1 事故的形态:回调函数单独测没问题,但共用函数被改挂的是下游。单元测试的"准"是以"窄"换来的——它精确告诉你"这个函数坏了",但"坏了影响谁"要集成测试回答。
写单元测试的两条纪律:
第一,测行为,不断言实现。
断言"标题超过 100 字返回错误"(行为),不断言"内部调用了 validateTitle 函数"(实现细节)。实现细节今天长这样,明天重构就变了——断言实现的测试在重构时全红,而且红的没有意义。 单元测试是重构的保镖,但前提是它只测行为。
第二,依赖隔离,替掉外部。
业务层不直接依赖数据库(第 12 章分层的红利),所以单元测试直接调 service 就行。遇到必须依赖的外部东西(比如发通知的第三方接口),用测试替身(mock/stub)——一个假的实现,记录被调用了、返回预设结果。
替身的原则:替掉"外部",不替"规则"。 替掉网络、数据库、时间,不替校验、计算、状态。
测试命名是文档。
测试名应该是一句完整的话——"标题超过 100 字应拒绝" 比 "test1" 有用一百倍。测试挂了,测试名就是第一份错误报告:看到 "金额加法不丢精度" 红了,不用看代码就知道哪条规则被破坏。测试名写不清楚,说明这条规则本身就没想清楚。
18.4 中层:集成测试——契约与权限的考场
集成测试是金字塔的中层。单元测试管单个函数对不对,集成测试管多个组件拼在一起对不对。
契约测试:把联调问题前置
it('GET /v1/contents 返回结构符合契约', async () => {
const res = await api.get('/v1/contents');
expect(res.status).toBe(200);
expect(res.data.items[0]).toMatchShape({
id: 'number',
title: 'string',
author_name: 'string',
});
});
后端改了返回结构,契约测试立刻红,不用等前端联调时才发现字段对不上。 契约测试是联调问题的自动化防线。
版本化也要跟着测:v1 的接口还在被旧调用方用,v1 的契约测试就不能撤;v2 上线,v2 的契约测试同步加上。接口承诺几套,测试账本就有几本。
越权测试:权限表的批量验证
it('未登录删除内容返回 401', async () => {
const res = await api.delete('/v1/contents/10'); // 不带凭证
expect(res.status).toBe(401);
});
it('非作者删除返回 403', async () => {
const res = await api.delete('/v1/contents/10'); // 带小明的凭证,删老周的内容
expect(res.status).toBe(403);
});
集成测试的"真",是价值也是成本。
数据库是真的(测试库)、HTTP 是真的(起测试实例),所以它能测出"合起来才暴露"的问题——契约不一致、权限漏洞、事务边界。但也因此比单元测试慢、脆、依赖环境。
这就是金字塔中层不能太多的原因: 慢就没办法每次改代码都跑,脆就容易偶发红,红了就被无视("狼来了"效应)。
集成测试也可以测缓存逻辑:未命中时查库并写回、命中时不再查库、TTL 到期后重新加载。
也可以验证事务边界。单元测试测不到"事务真的回滚了",集成测试可以验证——扣库存后建订单失败,库存必须还在:
it('建订单失败时库存回滚', async () => {
await forceOrderInsertError(); // 让建订单抛错
const stock = await db.query('SELECT stock FROM product WHERE id=101');
expect(stock).toBe(10); // 库存没被扣,回滚了
});
日志埋点也可以断言:下单成功必须有 order_no 日志。日志是排障原料,"没打日志"也是一种缺陷。
18.5 塔尖:E2E——完整旅程与重放
金字塔的塔尖是 E2E 测试,它测的是完整旅程——之前的联调清单(登录 → 发内容 → 看列表 → 评论 → 看详情 → 删除 → 登出)的自动化版:模拟真实用户走完全程,断言每一步的结果。
// E2E:完整旅程
it('用户下单完整旅程', async () => {
await login('xiaoming'); // 登录
const order = await createOrder('camera-101'); // 下单
expect(order.status).toBe('PENDING');
await payCallback(order.order_no, 'SUCCESS'); // 模拟渠道回调
expect(getOrder(order.order_no).status).toBe('PAID');
});
E2E 是唯一从用户视角出发的测试——前端、后端、数据库、网络全链路一起验证。代价也很明显:慢(分钟级)、贵(要起整套环境)、脆(任意一层波动都可能假失败)。所以 E2E 只覆盖核心路径——下单、支付、登录这些交易的核心旅程,不追求面面俱到。
测什么的判断标准:高频且贵。
用户经常走,错了代价大——下单、支付、登录都符合。"修改头像"高频但便宜,不测;"对账"低频但贵,有专门脚本。其余交给下层。
E2E 最大的敌人是"脆"——忽红忽绿。两个对策:
第一,显式等待。 页面渲染和网络请求都是异步的,最常见假失败是"断言跑在结果前面"。不要固定等待 2 秒,要等到元素出现再断言。
第二,隔离外部。 支付渠道、短信网关这些第三方,用可控的替身代替。外部一波动,E2E 就"狼来了"。
E2E 的稳定性是维护出来的。写一条一小时,保它不 flaky 是持续投入——这也是塔尖只能少的另一个原因。
E2E 还有另一个形态:上线后的健康检查。
把核心路径的 E2E 部署到生产环境定时跑——用户还没报障,它先发现"下单挂了"。这就是合成监控——E2E 从开发工具变成运维哨兵。
数据必须可控。 测试环境用 seed 脚本;生产环境的合成监控用专用测试账号——不污染真实数据,也不被真实数据干扰。数据不可控,断言就不可靠:"订单列表第一条"到底是哪条?
第 17 章的重放测试,是 E2E 的变体,专测幂等防线:
it('同一 order_no 下单两次只产生一个订单', async () => {
const orderNo = 'order-20260815-001';
await createOrder('camera-101', orderNo);
const second = await createOrder('camera-101', orderNo); // 重放
expect(second.order_no).toBe(orderNo);
expect(countOrders(orderNo)).toBe(1);
});
同一个下单请求发两次,只产生一个订单;同一个回调发三次,订单状态只推进一次。重放测试测的是"防线的防线"——幂等逻辑写错了,平时看不出来,只有重放才暴露。
第 17 章的错误清单,在测试章都有对应:
| 根源 | 测试类型 | 场景 |
|---|---|---|
| 重复 | 重放测试 | 同一请求发两次 |
| 乱序 | 状态机测试 | 取消后回调到达(单元测试覆盖非法转移) |
| 丢失 | 主动查询测试 | 回调不来,轮询补上 |
错误清单是"环境的不可靠清单",测试就是"针对不可靠的演练"。
18.6 测试与需求:承诺的映射
三层都建好了,退一步看全局:测试到底在测什么?
测的是承诺。 把前面章节的承诺清单翻出来,每一份承诺都对应一批测试:
| 承诺(来源) | 测试形态 | 例子 |
|---|---|---|
| 验收标准 | 单元 / 重放测试 | "标题 ≤ 100 字" → 一条断言;"重复点击只产生一个订单" → 重放 |
| 接口契约 | 契约测试 | 返回结构 / 状态码 / 错误码逐字段断言 |
| 权限表 | 越权测试 | 每个接口三问:401 / 403 / 200 |
| 交易防线 | 防线测试 | 状态机转移表、重放、对账脚本 |
测试计划是"给承诺写测试"。 承诺在哪里,测试就在哪里。承诺新增,测试跟着新增;承诺变更,测试跟着改。
两个信号告诉你"承诺和测试脱节了":
- 线上事故:事故 = 漏掉的承诺,补测试
- 评审时问不出问题:没有测试可看,只能看代码,评审自然冷场
测试挂了怎么办?先判断"谁坏了"。
- 需求变了,承诺变了 → 改测试。旧的测试作废,新的承诺需要新的断言
- 代码没照承诺做 → 改代码。承诺没变,代码违约
新需求的标准流程:
需求 → 验收标准(第 4 章) → 测试(本章) → 代码
验收标准写下的那一刻,测试的骨架就定了——每条验收标准对应一条断言。先写测试再写代码(TDD),写的时候目标明确(让测试绿),写完立刻有回归保护。至少也要做到代码和测试一起提交——测试是交付的一部分。
18.7 测试投入的决策:金字塔比例
什么该测,什么不该测?
| 该测 | 不该测(或低优先级) |
|---|---|
| 业务规则(校验、计算、状态转移) | 框架行为(React 怎么渲染、Express 怎么路由——框架自己测过了) |
| 契约(接口结构、状态码) | 纯展示细节(样式、文案) |
| 权限(401/403) | 一次性脚本(跑完就扔的迁移脚本) |
| 核心路径(下单、支付) |
边界纪律:测自己的,不测别人的。 但注意反向错误——不用测框架,但要测"我们用框架的方式"。我们写的业务逻辑(组件逻辑、中间件配置)是自己的代码,该测。
覆盖率一句话:看趋势,不看数字。 100% 覆盖率的烂测试(只断言"不报错")比 50% 覆盖率的真测试差。
写测试的时机跟着风险走:
- 改 bug 时先写测试——先用测试证明 bug,再修绿。证明修复有效且不再复发
- 新功能上线前补上——先写后写都行,关键是要有
- 重构前确认已有测试——没有就先补核心路径。没有测试的重构叫"重写",行为变没变没人知道
测试是重构的前提,不是结果。 后面会展开:技术债靠重构还,重构的胆量来自测试。
时间预算:开发时间的 20-30%。 写 2 小时代码配 30-40 分钟测试。这笔投入是把将来返工的时间提前花。比例过低信号:bug 反复修;比例过高信号:测试比代码还难维护。
测试的进化路径:
纸面清单(联调)→ 手工脚本(重放雏形)→ 分层测试(本章) → CI(提交自动跑)。先有测试,再谈自动化。
18.8 本章对应表
| 业务诉求 | 技术选择 | 为什么 | 代价/取舍 |
|---|---|---|---|
| 改坏了怎么办(回归) | 测试金字塔(单元/集成/E2E) | 改动后确认"还能跑",业务越重需求越硬 | 测试代码也是负债(维护/运行/排查) |
| 快速反馈 | 单元测试为主(金字塔底座) | 毫秒级、好定位、不依赖环境 | 测不到"合起来"的问题 |
| 契约/权限合起来才对 | 集成测试(契约/越权) | 第 7 章契约+第 13 章权限表的测试化 | 依赖真环境,比单元慢 |
| 完整旅程必须通 | E2E(核心路径) | 第 15 章联调清单自动化,用户视角唯一 | 慢/贵/脆,只覆盖核心 |
| 交易防线不能破 | 防线测试(状态机/重放) | 第 17 章错误清单的测试版 | 需要专门设计(重复/乱序/丢失) |
| 测试写多少 | 投入取舍(金字塔比例) | 反馈速度/覆盖深度/维护成本三围均衡 | 比例是起点不是教条 |
本章小结
测试是"改了还能跑"的保险,不是"证明代码对"的奖状。
阶段 3 的"改 A 崩 B"事故(18.1)逼出了分层测试体系。
测试金字塔是投入分配的原则:
- 单元测试(底座,70%):测业务规则——校验、计算、状态转移。毫秒级、便宜、定位准。
- 集成测试(中层,20%):测"合起来才对"——契约一致性、权限漏洞、事务回滚、缓存逻辑。
- E2E 测试(塔尖,10%):测完整旅程——核心路径(下单、支付、登录)。慢且贵,只覆盖高频且贵的场景。
测试在测什么?测承诺。 第 4 章验收标准、第 7 章接口契约、第 13 章权限表、第 17 章交易防线——承诺在哪里,测试就在哪里。 承诺变了测试跟着改,代码违约了测试必须红。
两条纪律:
- 测试挂了先判断"谁坏了"——需求变了改测试,代码违约改代码。测试是承诺的守门员,不能跟着球员跑。
- 该测自己的规则,不测别人的框架。覆盖率看趋势不看数字。
测试代码也是负债——要维护、要在 CI 里跑、要排查。写测试花的是现在的时间,省的是未来返工的钱。比例参考开发时间的 20-30%,跟着风险走:改 bug 时先写测试,重构前确认测试在。
进化路径: 纸面联调清单 → 分层测试(本章) → 第 22 章 CI 自动化。先有测试,再谈自动化。
下一章
测试体系立起来了:改动了代码,几分钟内就能确认"没改坏"。但测试只能确认"我们以为对的是对的"——如果攻击者看到的东西和我们看到的不一样呢?
业务中涉及了交易,有了用户数据(注册信息、订单、地址)——树大招风:XSS、SQL 注入、CSRF、越权……之前提到过"信任模型必须从第一行代码就假设对方不怀好意",下章我们开始对安全方面进行加固。