测试要让人敢改代码
测试题最容易被问倒的地方不是不会写,而是不知道该测什么:测实现还是测行为、mock 到什么程度、覆盖率怎么看。这三个问题答错,写再多测试也是负债。
01按成本选层级单元多、组件次、E2E 只留关键路径。别被覆盖率牵着走。
02测行为不测实现问自己"换个写法还过不过",不过就说明测错了东西。
03认清假测试只断言 mock 被调用、忘记 await,这两种通过率 100% 但毫无价值。
1
测试金字塔:为什么单元测试要多★
不是教条,是成本曲线
金字塔不是"单元测试更高级",而是成本和反馈速度的权衡:单元测试毫秒级、失败原因精确;E2E 分钟级、失败原因模糊。所以"该测什么"由"出错的代价"决定,不是由"哪种测试更时髦"决定。
三层各管什么【单元测试】纯函数、工具、算法、状态逻辑
速度:毫秒
失败定位:精确到函数和分支
数量:最多
例:formatMoney、debounce、权限判断函数
【组件/集成测试】组件的渲染和交互
速度:百毫秒
失败定位:到组件级别
数量:中等
例:表单校验、列表渲染、loading 与空态切换
【E2E】关键业务流程
速度:秒到分钟
失败定位:模糊(可能是网络、可能是选择器、可能是真实 bug)
数量:最少,只覆盖"挂了就发不出去"的路径
例:登录、下单、支付
- 为什么 E2E 不能多:慢(每次都要起服务、开浏览器)、脆(UI 改个类名就挂)、贵(排查成本高)。一个 E2E 挂十次八次是环境问题,团队就会开始忽略红灯,那测试就白写了。
- 金字塔的常见变体是"奖杯模型":组件测试最多,单元次之,E2E 最少。因为前端大量逻辑在组件里,纯函数不多。这个模型对前端更实用。
- 测试的价值不是覆盖率,是"敢改":重构时不用提心吊胆,这是最实在的收益。如果一个测试让你不敢改代码,那它测错了东西。
面试怎么答测试金字塔的本质是成本和反馈速度的权衡,不是哪种测试更高级。单元测试跑得快、失败原因精确、成本低,E2E 慢而且脆弱,UI 改个类名就可能挂。所以单元测试最多,E2E 只覆盖登录、下单、支付这类挂了就发不出去的路径。前端更常用的是奖杯模型,组件测试最多,因为前端大量逻辑在组件里。测试的核心价值是让人敢改代码,如果一个测试让你不敢重构,说明它测错了东西。
2
Vitest 与 Jest:现在怎么选
API 几乎一样,差别在生态
两者 API 基本兼容(
describe / it / expect 一样),主要差别是:Vitest 原生 ESM + 复用 Vite 配置,启动快;Jest 生态更成熟、更多历史项目在用。新项目用 Vite 就选 Vitest,几乎不用额外配置。两个关键差别【ESM 支持】
Jest 需要 babel / ts-jest 转换,配置成本高
Vitest 原生跑 ESM,复用 vite.config.ts 里的 alias 和插件
→ 改一处配置,构建和测试都生效
【速度】
Vitest 用 Vite 的模块图,只编译被引用的部分
大项目里冷启动差异明显
【生态】
Jest:成熟、插件多、问题搜得到答案
Vitest:成长中,但常用能力(jsdom、覆盖率、mock)都齐了
- 不要混用:同一个项目里 Vitest 和 Jest 的全局 API 有细微差异(
vivsjest命名空间),混着写会互相污染。 - 迁移成本其实很低:主要是把
jest.fn()换成vi.fn()、jest.mock换成vi.mock,再改 import。 - node 环境还是 jsdom:算逻辑用
node环境更快;要渲染组件才用jsdom或happy-dom。happy-dom比 jsdom 快很多,但兼容性略差。
面试怎么答Vitest 和 Jest 的 API 基本兼容,describe、it、expect 都一样。主要差别是 Vitest 原生支持 ESM 并且复用 Vite 配置,所以别名、插件、环境变量不用重复配,冷启动也更快;Jest 生态更成熟,历史答案多。新项目用 Vite 就选 Vitest 基本零配置。需要注意不要混用,因为 vi 和 jest 两个命名空间会互相污染,迁移主要是把 jest 前缀换成 vi。
3
组件测试:测用户看到什么★
Testing Library 的核心主张
Testing Library 只有一条主张:像用户一样找元素——按文字、按角色、按 label 找,不按 class 和 id 找。这样测试不会因为改样式而挂,而且天然验证了可访问性(如果按角色找不到,说明无障碍做错了)。
选择器优先级(官方推荐顺序)1. getByRole ← 首选,按钮、输入框、标题
2. getByLabelText ← 表单字段
3. getByPlaceholderText
4. getByText ← 普通文本
5. getByDisplayValue
6. getByAltText ← 图片
7. getByTitle
8. getByTestId ← 最后手段,改成纯样式时也不要动它
getBy* 找不到就抛错(同步)
queryBy* 找不到返回 null(用于断言"不存在")
findBy* 返回 Promise(用于等待异步出现)
- 为什么不用 class 选:样式重构是常态,用 class 选的测试会成为重构阻力。这类测试经常被删掉——那就等于没测。
getByTestId什么时候合理:元素没有任何语义(比如一个纯图标的按钮,没有文字)。这时先修无障碍(加aria-label),实在不行才用 testid。- 为什么要
userEvent而不是fireEvent:前者模拟完整交互(focus、键盘、点击顺序),后者只派发单个事件。很多 bug 只在完整交互下才出现。 - 别忘了测三种状态:loading、空数据、错误。只测成功路径的组件测试,上线后最容易在这三处翻车。
面试怎么答组件测试的原则是像用户一样找元素,按角色、label、文字找,不按 class 和 id 找。这样样式重构不会让测试挂,而且按角色找不到就说明无障碍做错了,等于顺便验证了可访问性。选择器优先级是 role 最好,其次 label、text,testid 是最后手段。交互要用 userEvent 而不是 fireEvent,因为它模拟完整的 focus、键盘、点击顺序。另外一定要补 loading、空态、错误这三种状态。
4
mock:什么时候该 mock,什么时候不该★
过度 mock 的测试等于自欺欺人
mock 的判断标准只有一条:这个依赖是否属于"被测单元的外部边界"。网络请求、时间、随机数、第三方 SDK 该 mock;同项目里的其他模块、以及你要验证的行为逻辑,不该 mock——mock 掉就等于把要测的东西换成了假的。
- "只验证 mock 被调用"是最常见的假测试:
expect(api.save).toHaveBeenCalled()通过,但保存的数据格式可能错得离谱。要断言参数内容,或者干脆不 mock 这个函数,断言最终副作用。 - 假时钟的坑:
vi.useFakeTimers()之后await微任务仍会执行,但setTimeout不会自己走,要手动vi.advanceTimersByTime(30000)。忘了推进就会永远等在那里。 - MSW 是更好的网络 mock:它在网络层拦截(Service Worker 或 fetch 层),组件代码完全不用改,测出来的行为和真实环境一致。比 mock 掉整个 api 模块更接近真实。
- 什么时候不写测试:纯展示组件、样式逻辑、以及一眼能看出的地方。为了覆盖率去测
const a = 1是浪费时间。
面试怎么答mock 的判断标准是这个依赖是不是被测单元的外部边界。网络请求、时间、随机数、第三方 SDK 应该 mock,因为不 mock 测试会不稳定;但同项目里的业务模块不该 mock,mock 掉就等于把要测的东西换成假的。最常见的假测试是只断言 mock 被调用,却不断言参数和最终结果。网络 mock 更推荐用 MSW,它在网络层拦截,组件代码完全不用改,行为更接近真实。
5
覆盖率:怎么读才不被骗
100% 覆盖率不代表没有 bug
覆盖率衡量的是"代码被执行过",不是"逻辑被验证过"。一个
if 走通了但没有断言,覆盖率照样算。所以覆盖率是发现"哪里没测"的工具,不是"测试好不好"的指标。◐ 可交互:把断言全关掉,看覆盖率还涨不涨
01formatMoney(1234.5) 返回 ¥1,234.50覆盖 18 行
02空数组时返回 "¥0.00"覆盖 6 行
03非法输入不抛错覆盖 12 行
04大数字用千分位覆盖 9 行
行覆盖率100%
真实断言数4
能抓到的 bug4 类
关掉断言后,覆盖率不会掉 —— 它只统计"代码被执行过"。
四种覆盖率,区别在哪行覆盖 line
每一行执行过就算覆盖
最容易注水:调用一次函数就 100%
分支覆盖 branch
if / else 两个方向都走过算覆盖
比行覆盖有意义,但仍不保证断言正确
函数覆盖 function
每个函数被调用过
语句覆盖 statement
类似行覆盖
真正有意义的是"变异测试":
故意把代码改错(> 改成 >=),
看测试会不会失败。不失败说明这个断言是空的。
- 覆盖率最容易注水的地方:把测试写成
it('不报错', () => { expect(() => fn()).not.toThrow(); }),执行了但什么都没验证。覆盖率上去了,bug 一个没少。 - 合理的阈值:新项目 70-80% 是合理目标;核心库(工具、算法、状态逻辑)可以要求 90%+;UI 组件不必强求。强制 100% 的结果通常是大量
expect(true).toBe(true)。 - 看"未覆盖的行"比看百分比有用:打开 coverage 报告,看那些红色的分支——往往正好是错误处理和边界情况,这些才是最容易出 bug 的地方。
- 增量覆盖率更实用:只要求"本次改动新增的代码"覆盖率达到 80%,比整体百分比更有约束力,也不会逼着人去补老代码。
面试怎么答覆盖率衡量的是代码被执行过,不是逻辑被验证过。一个 if 走通了但没写断言,覆盖率照样算。所以它的价值是发现哪里没测,不是评价测试质量。最容易注水的写法是只断言不抛错。实际使用上,打开报告看未覆盖的分支更有用,那些通常正好是错误处理和边界情况;新项目 70-80% 是合理目标,核心逻辑可以高一些,UI 组件不必强求,强制 100% 的结果往往是写一堆空断言。
6
E2E:Playwright 与 Cypress
只测关键路径
E2E 的价值在于验证整条链路能走通,不在于覆盖面。所以只写"挂了就发不出去"的路径:登录、核心下单、支付、关键表单提交。工具上 Playwright 现在更主流(多浏览器、多标签页、无 iframe 限制),Cypress 开发体验好但限制多。
选型差别Playwright
✅ Chromium / Firefox / WebKit 三套引擎
✅ 多标签页、多窗口、iframe 都支持
✅ 自动等待(元素可交互才操作),不需要手写 waitFor
✅ 可以直接调 API 做数据准备(request fixture)
❌ 生态相对新
Cypress
✅ 开发时的时间旅行调试体验很好
✅ 上手简单
❌ 单标签页限制(跨域跳转要特殊处理)
❌ iframe 支持差
❌ 同一时刻只能跑一个浏览器
- E2E 最大的敌人是不稳定:不要用固定
sleep(2000),用"等待某个元素出现"或"等待某个网络响应"。Playwright 的自动等待已经处理了大部分场景。 - 数据要能自己准备和清理:依赖测试环境里"已经有的数据"是最常见的坑,别人改了就挂。每次跑之前用 API 造数据,跑完清理。
- 只跑关键路径:10 条 E2E 跑 5 分钟可以接受,100 条跑 50 分钟就没人跑了。数量控制在个位数到十几个。
- 用 trace 排查:Playwright 可以录 trace(视频 + 快照 + 网络),CI 失败时下载下来回放,比自己猜快得多。
面试怎么答E2E 的价值是验证整条链路能走通,所以要克制,只测登录、核心下单、支付、关键表单这类挂了就发不出去的路径,数量控制在十几条以内。工具上 Playwright 现在更主流,支持三套浏览器引擎、多标签页、iframe,还有自动等待;Cypress 调试体验好但有单标签页和 iframe 的限制。E2E 最大的敌人是不稳定,所以不要用固定 sleep,要等元素或等网络响应,并且测试数据要自己造自己清。
7
测行为还是测实现★
这条判断决定了测试值不值钱
一句话原则:测"输入什么、输出什么",不测"中间怎么做的"。测实现的测试在重构时必然失败,而它失败不代表代码错了——这种测试就是负债。
测实现(会变成负债)
- 断言组件内部 state 的值
- 断言某个私有方法被调用了
- 断言具体的 DOM 结构层级
- 断言调用了哪几个 API、按什么顺序
- 断言 class 名
测行为(重构友好)
- 断言渲染出来的内容
- 断言用户操作后的可见结果
- 断言最终发出的请求体
- 断言页面跳转到了哪里
- 断言错误提示出现了
同一个需求,两种写法需求:输入金额,超过 1000 打九折
【测实现】❌
it('调用了 calcDiscount', () => {
render(<Price />);
input('1200');
expect(spyCalc).toHaveBeenCalledWith(1200);
});
→ 以后把打折逻辑内联进来,这个测试就挂了,
但功能完全正常。
【测行为】✅
it('超过 1000 显示折后价', () => {
render(<Price />);
input('1200');
expect(screen.getByText('1080')).toBeInTheDocument();
});
→ 不管内部怎么重构,只要结果对就通过。
- 怎么判断自己测的是实现:问一句"如果我用完全不同的写法实现同样功能,这个测试会不会挂"。会挂,就是测了实现。
- 内部状态该不该测:不该。如果有状态逻辑值得测,把它抽成纯函数单独测,那才是真的单元测试。这也反过来推动代码结构变好。
- 例外:性能相关的实现细节:比如"必须用 useMemo 避免重复计算",这类断言只能测调用次数。但这类测试要注明原因,否则后人不知道为什么。
面试怎么答原则是测输入和输出,不测中间怎么做的。测实现的测试在重构时必然失败,而失败不代表代码错,这种测试就是负债。判断方法是问自己"如果换一种完全不同的写法实现同样功能,这个测试会不会挂",会挂就说明测了实现。值得测的内部逻辑应该抽成纯函数单独测,这反过来也会推动代码结构变好。
8
异步测试与假时钟
为什么 await 写错了测试就假通过
异步测试的两个陷阱:忘记 await 导致断言没执行、用固定 sleep 导致测试又慢又脆。正确做法是用
findBy* 等元素出现,或者用假时钟精确推进时间。- ESLint 能抓这个问题:
@typescript-eslint/no-floating-promises会报"没处理的 Promise"。测试代码一定要开这条规则,"忘记 await"是最隐蔽的假通过。 - 假时钟和
await会打架:开了假时钟之后,waitFor里基于真实时间的轮询会卡住。要用vi.advanceTimersByTime或vi.runAllTimers手动推进。 queryBy和findBy的分工:断言"不存在"用queryBy(返回 null 不抛错),断言"稍后出现"用findBy(返回 Promise)。用getBy断言不存在会直接抛错,测试反而失败在意外的地方。- 别用
waitForTimeout:那是固定 sleep 的变体,等于把不稳定的问题掩盖成"多等一会"。要等具体条件。
面试怎么答异步测试最常见的错误是忘记 await,测试会直接通过但断言根本没执行,这是最隐蔽的假通过,所以要开 no-floating-promises 这条 lint 规则。第二是不要用固定 sleep,要等具体条件,断言"稍后出现"用 findBy,断言"不存在"用 queryBy。测定时逻辑用假时钟手动推进时间,但要注意假时钟和基于真实时间的轮询会打架,得用 advanceTimersByTime 配合 waitFor。
9
CI 里怎么跑
让测试真正拦住问题
测试写好了不代表有用,必须卡在合并前。最小可用方案是:PR 时跑单元和组件测试,主分支合并后跑 E2E,并且红灯必须真的拦住合并——一旦允许"先合再说",测试就死了。
一条合理的分层流水线【PR 触发】
lint + type check 30 秒
单元 + 组件测试(只跑受影响的) 1-2 分钟
→ 任一失败,禁止合并
【合并到主分支】
全量单元测试
构建 + 部署到预发
E2E 冒烟(十几条关键路径)
→ 失败自动回滚
【定时(每天)】
全量 E2E
依赖漏洞扫描
性能基线对比
- 只跑受影响的测试:大仓库里全量跑太慢。Vitest 的
--changed、Jest 的--onlyChanged能按 git 变更筛。Turborepo / Nx 的缓存更彻底。 - 并行分片:Playwright 的
--shard=1/4把 E2E 分到多台机器,总时长能砍到四分之一。 - 失败必须有产物:截图、视频、trace、日志。只给一句"测试失败",排查成本比写测试还高,最后就没人看。
- 不稳定测试要立刻修或删:偶尔挂的测试比没有更糟——团队会习惯"重跑一次",然后真的 bug 也被重跑掉了。要么修到稳定,要么删掉重写。
面试怎么答测试写在 CI 里才有价值,关键是红灯必须真的拦住合并,一旦允许先合再说测试就等于没有。合理分层是 PR 时跑 lint、类型检查和单元组件测试,几分钟内出结果;合并后跑构建加 E2E 冒烟;每天定时跑全量和依赖扫描。大仓库要用增量测试和并行分片控制时间,失败时必须产出截图、trace、日志。另外不稳定测试要立刻修或删,否则团队会习惯重跑,真 bug 也会被重跑掉。
10
速答清单
考前十分钟只看这一页
01测试金字塔为什么单元测试多?
成本和反馈速度的权衡。单元测试毫秒级、失败定位精确;E2E 分钟级、失败原因模糊还可能是不稳定。跟"哪种更高级"无关。
02前端更常用什么模型?
奖杯模型:组件测试最多,单元次之,E2E 最少。因为前端大量逻辑在组件里,纯函数不多。
03测试的核心价值是什么?
让人敢改代码。如果一个测试让你不敢重构,说明它测错了东西,是负债。
04Vitest 和 Jest 怎么选?
新项目用 Vite 就选 Vitest,原生 ESM 且复用 Vite 配置,零额外配置;老项目或需要成熟生态用 Jest。不要混用。
05组件测试按什么找元素?
按 role 最好,其次 label、text,testid 是最后手段。按 class 找的测试会在样式重构时失效,等于没测。
06userEvent 和 fireEvent 的区别?
userEvent 模拟完整交互(focus、键盘、点击顺序),fireEvent 只派发单个事件。很多 bug 只在完整交互下出现。
07组件测试必须补哪些状态?
loading、空数据、错误。只测成功路径的测试,上线后最容易在这三处翻车。
08什么时候该 mock?
依赖属于被测单元的外部边界时:网络、时间、随机数、第三方 SDK。同项目的业务模块不该 mock。
09什么叫假测试?
只断言 mock 被调用,却不断言参数和最终结果。通过但什么都没验证。
10网络 mock 用什么更好?
MSW。它在网络层拦截,组件代码不用改,行为更接近真实环境,比 mock 掉整个 api 模块好。
11覆盖率能说明什么?
只能说明代码被执行过,不说明逻辑被验证过。if 走通了没写断言也算覆盖。它的价值是发现哪里没测。
12覆盖率怎么防注水?
看未覆盖的分支而不是百分比,那些通常是错误处理和边界。或者要求增量代码的覆盖率,比整体百分比更有约束力。
13怎么判断测试测了实现?
问"换一种完全不同的写法实现同样功能,这个测试会不会挂"。会挂就是测了实现。
14异步测试最容易犯什么错?
忘记 await,测试直接通过但断言没执行。要开 no-floating-promises 规则。
15测定时逻辑怎么做?
用假时钟手动 advanceTimersByTime,不要真等。注意假时钟和基于真实时间的轮询会打架。
16E2E 为什么要克制?
慢、脆、贵。挂十次八次是环境问题时团队就会忽略红灯,那测试就白写了。只测挂了就发不出去的路径。
17CI 里测试怎么才有用?
红灯必须真的拦住合并。一旦允许"先合再说",测试就形同虚设。失败必须有截图、trace、日志等产物。