前端测试方法论:从编码哲学到分层实施

Author

华丽

PublishDate

2026-08-06

category

编程

第一部分:核心哲学

1.1 总纲

一段业务代码的价值,取决于它做的「决策」能被多便宜地验证。

“难测"很少是测试技术不行,更多是代码把该收敛的决策埋进了不该负责它的地方(UI、网络、时序里)。测试的困难是症状,病根在结构。所以顺序要反过来:先用"这该怎么测"倒逼"这该怎么写”。

1.2 关键区分:决策 vs 执行

任何业务代码都由两类成分组成,能不能分开它们,基本决定了这段代码好不好测:

成分 特征 例子 可测性
决策(Decision) 输入→输出,无副作用,有分支 “权限不够→拒绝”;“文件已删→回退+提示” 纯函数,单测毫秒级
执行(Effect) 读写外部世界,有副作用 发请求、跳路由、弹 toast、改 store 需 mock,较贵

坏味道:一个函数里既有 if (权限)router.push(),决策与执行耦合,只能连路由一起测。 好结构:决策抽成纯函数,执行退化成"调用决策结果"的薄壳。

1.3 三条编码判据(写码 / 评审时自检)

写完一段代码,用三条判据过一遍,任何一条答不上来,就是结构该调整的信号。

  1. 决策与执行分离了吗? 这个函数里有没有"既做判断、又碰副作用"?有 → 把判断抽成纯函数。
  2. 状态机显性化了吗? 涉及"多步骤+异步+外部可变"时,状态流转是否抽成了可穷举的纯函数,而不是散落在回调里的隐式 if?
  3. 副作用收口到边界了吗? 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.formatlodash.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"的三道筛子(全中才写)

  1. 跨边界:这一步是否越过当前模块边界(跨视图 / 跨前后端 / 跨进程)?
  2. 有时序:结果是否依赖"两次调用之间外部世界变了"(操作时资源被删、并发冲突)?
  3. 下沉不了:把决策抽成纯函数后,是否仍剩"必须真实跑一遍才能确认"的部分?

第一道筛子就会刷掉大半"感觉很核心"的场景,它们的决策都能下沉成单测。

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 无背景、无文档、依赖不明。最危险的做法是"先大重构再补测试",重构时没有测试守护,等于裸奔。

  1. 默认不动旧代码,不为它补测试。 旧代码能跑就让它跑,不主动还债。
  2. 测试跟着需求走。 当需求/bug 逼你改某处时,只把"正在改的那一个分支"抽成可测纯函数,配单测。改哪测哪,不扩散。
  3. e2e 只给"新开发的主流程功能"写,不回头给已稳定运行的旧流程补。
  4. 可测性写进新代码的完成定义(评审问:决策点抽出来了吗?能单测吗?),只约束增量,不追缴存量。

存量项目补测试,不是"偿还旧债",是"不再产生新债,再借每次改动的契机顺手还一小块"。覆盖率随维护自然增长,一年后高危路径自然都有覆盖。


第六部分:速查卡

场景 做法
写新业务代码 过三判据:决策/执行分离?状态机显性?副作用收口?
写单元测试 只测纯逻辑与状态机;断言行为非实现;注入最外缘副作用
写集成测试 真实模块跑内部,最外缘注入,断言模块间契约
决定要不要 e2e 过三筛子:跨边界?有时序?下沉不了?全中才写
e2e 太贵 用有状态 mock 后端,不起真服务
碰存量旧代码 不改不补;改动时只抽当前分支成纯函数并配单测
e2e 越写越多 警报:决策没收口,回到三判据重构
闽 ICP 备 2021009779 号 - 1 | Copyright © 2020 华丽 | Powered By Hugo