第一部分:核心哲学
1.1 总纲
一段业务代码的价值,取决于它做的「决策」能被多便宜地验证。
“难测"很少是测试技术不行,更多是代码把该收敛的决策埋进了不该负责它的地方(UI、网络、时序里)。测试的困难是症状,病根在结构。所以顺序要反过来:先用"这该怎么测"倒逼"这该怎么写”。
1.2 关键区分:决策 vs 执行
任何业务代码都由两类成分组成,能不能分开它们,基本决定了这段代码好不好测:
| 成分 | 特征 | 例子 | 可测性 |
|---|---|---|---|
| 决策(Decision) | 输入→输出,无副作用,有分支 | “权限不够→拒绝”;“文件已删→回退+提示” | 纯函数,单测毫秒级 |
| 执行(Effect) | 读写外部世界,有副作用 | 发请求、跳路由、弹 toast、改 store | 需 mock,较贵 |
坏味道:一个函数里既有 if (权限) 又 router.push(),决策与执行耦合,只能连路由一起测。 好结构:决策抽成纯函数,执行退化成"调用决策结果"的薄壳。
1.3 三条编码判据(写码 / 评审时自检)
写完一段代码,用三条判据过一遍,任何一条答不上来,就是结构该调整的信号。
- 决策与执行分离了吗? 这个函数里有没有"既做判断、又碰副作用"?有 → 把判断抽成纯函数。
- 状态机显性化了吗? 涉及"多步骤+异步+外部可变"时,状态流转是否抽成了可穷举的纯函数,而不是散落在回调里的隐式 if?
- 副作用收口到边界了吗? API / router / store / Message 是否只出现在最外层,内核保持纯净可独立实例化?
1.4 一个对比示例
❌ 决策与执行耦合(难测):
async function grantPermission(fileId, userId) {
try {
await api.grant(fileId, userId);
Message.success('授权成功');
}
catch (e) {
if (e.code === 'FILE_DELETED') {
router.back(); // ← 决策埋在执行里
Message.error('文件已被删除');
}
else if (e.code === 'NO_PERMISSION') {
Message.error('无权限');
}
}
}
✅ 决策抽出、执行变薄(好测):
// 纯决策:无副作用,输入输出明确,可穷举所有分支
function decideGrantOutcome(result) {
if (result.ok) { return { type: 'toast', level: 'success', text: '授权成功' }; }
if (result.code === 'FILE_DELETED') {
return { type: 'exit_and_toast', text: '文件已被删除' };
}
return { type: 'toast', level: 'error', text: '无权限' };
}
// 薄执行:只负责调用副作用,无分支
async function grantPermission(fileId, userId) {
const result = await api.grant(fileId, userId).catch(e => e);
applyUIAction(decideGrantOutcome(result));
}
“文件被删要回退+提示"这个曾经的 e2e 难题,现在变成一行单测:
expect(decideGrantOutcome({ ok: false, code: 'FILE_DELETED' }))
.toEqual({ type: 'exit_and_toast', text: '文件已被删除' });
第二部分:单元测试(金字塔的地基)
绝大多数逻辑都应该在单元测试这一层被验证,它最快、最稳、定位最准。
2.1 单元测试测什么
只测纯逻辑:决策函数、状态机迁移、数据转换、工具函数。特征是"给定输入,断言输出”,不碰 UI、网络、时钟、存储。
describe('decideGrantOutcome', () => {
it('成功时提示授权成功', () => {
expect(decideGrantOutcome({ ok: true }))
.toEqual({ type: 'toast', level: 'success', text: '授权成功' });
});
it('文件被删时回退并提示', () => {
expect(decideGrantOutcome({ ok: false, code: 'FILE_DELETED' }))
.toEqual({ type: 'exit_and_toast', text: '文件已被删除' });
});
it('无权限时仅提示', () => {
expect(decideGrantOutcome({ ok: false, code: 'NO_PERMISSION' }))
.toEqual({ type: 'toast', level: 'error', text: '无权限' });
});
});
2.2 状态机的单元测试:注入可控依赖
对"多步骤+异步"的状态机(队列、上传、审批),不要 mock 内部逻辑,而是注入可控的外部依赖(时钟、执行器),驱动真实状态机跑:
function createHarness() {
const executed = [];
let active = false;
initQueue({
execute: async text => { executed.push(text); }, // 只注入最外缘副作用
hasActiveTask: () => active,
});
return { executed, setActive: v => { active = v; } };
}
it('活跃时入队不发送,结束后按 FIFO 弹出', async () => {
const h = createHarness();
h.setActive(true);
await enqueue('一');
await enqueue('二');
expect(h.executed).toEqual([]); // 活跃时不发
h.setActive(false);
await drain();
expect(h.executed).toEqual(['一', '二']); // 真实队列按序弹出
});
这其实是单元测试与集成测试的交界:模块是真实的,只有最外缘副作用被注入。它能钉死"顺序、竞态、失败重试"这类纯函数测不到的状态机契约。
2.3 单元测试的写法要点
- 断言行为,不断言实现:测"输入 X 应得输出 Y",不测"内部调用了 _helper 三次"。后者让测试绑死实现,重构即碎。
- 一个用例一个分支:每个
it只验证一个决策分支,失败时一眼定位。 - 复杂输出用快照:返回结构较大时用
toMatchInlineSnapshot,但 diff 需人工审查。 - 不测试第三方库:
dayjs.format、lodash.groupBy这些不需要你验证。
2.4 单元测试的反模式
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 断言内部实现细节 | 重构即碎,测试成负担 | 只断言输入→输出的可观察行为 |
| 为用例强行 mock 一切 | 测的是 mock 间的互动,什么也没验证 | 决策抽纯函数,减少需要 mock 的面 |
| 一个用例塞多个断言路径 | 失败定位困难 | 拆成多个独立用例 |
| 追求 100% 行覆盖 | 大量无意义断言 | 覆盖"决策分支"而非"行" |
第三部分:集成测试(最常被跳过、性价比最高的一层)
3.1 定位
集成测试测的是"多个真实模块协作时,跨边界的契约是否闭环"。
- 不测:单个纯函数的分支(单元测试)、完整 UI 渲染(组件/e2e)。
- 要测:模块 A 的输出,经过真实流转,真的驱动了模块 B 的预期行为。
3.2 核心手法:真实模块 + 只在系统最外缘注入
尽量少 mock:内部全部用真实实现,只在"系统与外部世界的边界"(网络、时钟、存储)做可控注入。
❌ 错误:mock 掉一堆内部模块,测的是"mock 之间怎么互动"。 ✅ 正确:真实跑内部模块,只注入最外缘副作用。
// 真实初始化多个协作模块,只把"发网络"换成记录
initAuth({ fetchUser: async () => fakeUser }); // 外缘:网络
initPermission({ fetchRoles: async () => fakeRoles });
it('无权限用户发起操作被拦截并提示', async () => {
await store.loadCurrentUser(); // 真实 auth 模块
const allowed = await store.tryAction(); // 真实 permission 模块消费
expect(allowed).toBe(false);
expect(uiStore.lastToast).toBe('无权限'); // 跨模块契约闭环
});
3.3 集成测试 checklist
- 被测的内部模块是真实实现吗(不是空壳 mock)?
- 注入的是不是只有最外缘副作用(网络/时钟/存储)?
- 断言的是模块间契约(A 驱动了 B),而不是某函数的实现细节?
- 够快吗(毫秒级、能进 CI)?慢,就是不小心写成了 e2e。
第四部分:E2E 测试(只留黄金路径)
4.1 判断"是否需要 e2e"的三道筛子(全中才写)
- 跨边界:这一步是否越过当前模块边界(跨视图 / 跨前后端 / 跨进程)?
- 有时序:结果是否依赖"两次调用之间外部世界变了"(操作时资源被删、并发冲突)?
- 下沉不了:把决策抽成纯函数后,是否仍剩"必须真实跑一遍才能确认"的部分?
第一道筛子就会刷掉大半"感觉很核心"的场景,它们的决策都能下沉成单测。
4.2 e2e 的成本控制:有状态 mock 后端
真正需要 e2e 时,也不要起真后端。用 MSW / 路由拦截做有状态 mock,关键是 mock 能在两次请求之间改变行为,从而模拟"外部世界变了":
let fileExists = true; // 可变的"服务端状态"
mockApi.post('/grant', () =>
fileExists ? ok() : error(404, 'FILE_DELETED'));
test('授权时文件恰好被删 → 回退并提示', async ({ page }) => {
await page.goto('/share/manager');
fileExists = false; // 模拟"操作瞬间被外部删除"
await page.click('text=授权给成员');
await expect(page).toHaveURL('/share/list');
await expect(page.locator('.toast')).toContainText('文件已被删除');
});
fileExists = false 一行就替代了"真删文件"的昂贵准备,e2e 因此能进 CI、不慢不脆。
4.3 健康占比
测试应是金字塔:大量决策单测打底,少量集成测试居中,极少 e2e 收尾。e2e 越写越多、越难维护,往往不是 e2e 本身的问题,是决策没收口在报警,该回到三条判据。
第五部分:存量项目的落地路径
绝大多数存量前端项目都没按上述原则写,且 commit 无背景、无文档、依赖不明。最危险的做法是"先大重构再补测试",重构时没有测试守护,等于裸奔。
- 默认不动旧代码,不为它补测试。 旧代码能跑就让它跑,不主动还债。
- 测试跟着需求走。 当需求/bug 逼你改某处时,只把"正在改的那一个分支"抽成可测纯函数,配单测。改哪测哪,不扩散。
- e2e 只给"新开发的主流程功能"写,不回头给已稳定运行的旧流程补。
- 可测性写进新代码的完成定义(评审问:决策点抽出来了吗?能单测吗?),只约束增量,不追缴存量。
存量项目补测试,不是"偿还旧债",是"不再产生新债,再借每次改动的契机顺手还一小块"。覆盖率随维护自然增长,一年后高危路径自然都有覆盖。
第六部分:速查卡
| 场景 | 做法 |
|---|---|
| 写新业务代码 | 过三判据:决策/执行分离?状态机显性?副作用收口? |
| 写单元测试 | 只测纯逻辑与状态机;断言行为非实现;注入最外缘副作用 |
| 写集成测试 | 真实模块跑内部,最外缘注入,断言模块间契约 |
| 决定要不要 e2e | 过三筛子:跨边界?有时序?下沉不了?全中才写 |
| e2e 太贵 | 用有状态 mock 后端,不起真服务 |
| 碰存量旧代码 | 不改不补;改动时只抽当前分支成纯函数并配单测 |
| e2e 越写越多 | 警报:决策没收口,回到三判据重构 |