13 · 测试与质量保障(详解版)
一句话定位:测试不是「证明代码对」,而是用可重复的成本,换取「改动不怕」的信心。质量保障则是把这种信心变成流程与门禁。
读完本篇你应该能做到
- 为一个功能选择合适的测试层级,而不是「全都写 E2E」;
- 用 Testing Library + MSW 写出稳定、面向行为的组件测试;
- 治理 flaky 测试、设计 CI 门禁、读懂覆盖率背后的假象;
- 建立团队级质量体系,把事故转化为可回归的用例。
0. 领域导论
0.1 这个领域解决什么问题
| 现象 | 根因 | 归属 |
|---|---|---|
| 改了一个工具函数,三个页面挂了 | 没有回归测试 | 单元/集成缺失 |
| E2E 天天红,重跑又绿 | 依赖固定等待、环境不稳定 | flaky |
| 覆盖率 85%,线上仍白屏 | 测了实现细节,没测关键路径 | 测试有效性 |
| UI 改版后布局错位没人发现 | 没有视觉回归 | 视觉测试 |
| 接口字段改名,前端上线才炸 | 没有契约测试 | 契约 |
测试的价值不在「数量」,而在在正确的位置,用最低的成本,覆盖最容易出错的行为。
0.2 一个类比
把质量保障想成汽车出厂检测:
- 单元测试是零件检测:螺丝、刹车片,快、便宜、定位准。
- 集成测试是组装检测:发动机装上车能不能转。
- E2E 测试是路测:整车开一圈,最真实但最贵最慢。
- 视觉回归是外观检查:漆面、缝隙对不对。
- 监控告警是上路后的仪表盘:出厂检测再全,也要看实际运行。
没有哪种检测能替代另一种。只做零件检测,装起来可能不转;只做路测,一次要半天,出问题还定位不到零件。
0.3 学习地图
13 测试与质量保障
├─ 前置:06 框架与组件化、09 工程化(CI)
├─ 紧接:18 可观测性(线上质量)、12 安全(安全测试)
├─ 交叉:11 性能(性能测试)、08 渲染(SSR 测试)
└─ 长期:20 工程效能(质量文化)
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会写单元与组件测试 | 用例集 | 能解释断言在测什么 |
| L2 进阶 | 会写 E2E、mock 网络、视觉回归 | 稳定测试套件 | 能判断该用哪一层 |
| L3 高级 | 懂可测试性设计与 flaky 治理 | 测试基础设施 | 能设计 CI 策略 |
| L4 专家 | 建质量体系与度量 | 质量门禁、规范 | 能对发布质量负责 |
1. L1 基础:写出第一个有意义的测试
1.1 测试的基本结构:AAA
import { describe, it, expect } from 'vitest';
import { calcDiscount } from './discount';
describe('calcDiscount', () => {
it('满 100 减 20', () => {
// Arrange 准备
const total = 120;
// Act 执行
const result = calcDiscount(total);
// Assert 断言
expect(result).toBe(100);
});
});
为什么强调三段:准备、执行、断言混在一起时,测试失败你分不清是数据问题、逻辑问题还是断言写错。
1.2 断言:测「行为」而不是「实现」
// ❌ 测内部实现:重构就挂,且没验证用户价值
expect(component.state.isOpen).toBe(true);
// ✅ 测可观察行为
expect(screen.getByRole('dialog')).toBeVisible();
| 断言 | 用途 |
|---|---|
toBe / toEqual | 原始值 / 深比较 |
toContain | 包含 |
toThrow | 抛错 |
toBeVisible | 可见 |
toHaveTextContent | 文本 |
toHaveBeenCalledWith | 调用参数 |
1.3 Testing Library:按「用户怎么找」来查询
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('输入关键词后展示结果', async () => {
const user = userEvent.setup();
render(<SearchPage />);
// 优先级:Role > Label > Placeholder > Text > TestId
await user.type(screen.getByRole('searchbox', { name: '搜索' }), '手机');
await user.click(screen.getByRole('button', { name: '查询' }));
expect(await screen.findByText('共 3 条结果')).toBeInTheDocument();
});
查询优先级与原因:
| 查询 | 对应真实用户行为 | 推荐度 |
|---|---|---|
getByRole | 读屏/键盘用户 | ★★★★★ |
getByLabelText | 表单填写 | ★★★★★ |
getByPlaceholderText | 视觉提示 | ★★★ |
getByText | 阅读文本 | ★★★★ |
getByTestId | 无其他手段 | ★★(最后选择) |
附带收益:按 Role 查询会强迫你写出可访问的 DOM。测试与可访问性一次做完。
1.4 三种查询的时机差异
| 方法 | 何时用 | 找不到时 |
|---|---|---|
getBy* | 元素应立刻存在 | 立即抛错 |
queryBy* | 断言「不存在」 | 返回 null |
findBy* | 异步出现 | 返回 Promise,超时抛错 |
expect(screen.queryByText('加载中')).not.toBeInTheDocument(); // 断言不存在用 query
expect(await screen.findByText('订单列表')).toBeInTheDocument(); // 异步用 find
1.5 覆盖率:先读懂再追求
| 指标 | 含义 | 注意 |
|---|---|---|
| Line | 行覆盖 | 一行执行过 ≠ 逻辑正确 |
| Branch | 分支覆盖 | 更能反映条件逻辑 |
| Function | 函数覆盖 | 容易虚高 |
| Statement | 语句覆盖 | 与行覆盖类似 |
vitest run --coverage
覆盖率的三个假象:
- 执行 ≠ 断言:代码跑过但没有
expect,覆盖率 100% 也没用; - 测试实现细节:覆盖率很高,但重构就挂、真实行为没测;
- 关键路径没覆盖:支付失败、网络异常、权限不足这些分支往往最缺。
1.6 L1 小结与自检
| 主题 | 关键结论 |
|---|---|
| 结构 | Arrange / Act / Assert |
| 断言 | 测行为,不测内部状态 |
| 查询 | 按用户视角,Role 优先 |
| 时机 | get 立刻、query 断言不存在、find 异步 |
| 覆盖率 | 是参考,不是目标 |
自检题
- 为什么
getByTestId应该最后才用?——它不表达用户可见语义,测试与实现耦合;优先用 role、label、可见文本查询,测行为而不是标记。 - 覆盖率 100% 为什么仍可能有严重 bug?——覆盖只证明代码被执行过,不证明断言正确;分支组合、异常路径、集成问题与需求理解错误都不在覆盖里。
- 断言「不存在」为什么必须用
queryBy?——getBy找不到会直接抛错,无法表达「不存在」;queryBy返回null,可安全断言。
2. L2 进阶:分层与真实环境
2.1 测试金字塔 vs 测试奖杯
金字塔(传统) 奖杯(前端更实用)
/\ E2E 少量 /\ E2E 少量
/ \ 集成 适量 / \ 集成(组件)最多
/____\ 单元 大量 /____\ 单元 适量
为什么前端更偏向奖杯:前端的价值在「组合后的行为」,纯函数单元测试收益有限;组件级集成测试(渲染 + 交互 + mock 网络)性价比最高。
判断标准:
| 你测的东西 | 层级 |
|---|---|
| 纯计算、格式化、状态机迁移 | 单元 |
| 组件渲染与交互 | 组件/集成 |
| 跨页面关键流程 | E2E |
| 接口字段兼容 | 契约 |
| 样式外观 | 视觉回归 |
2.2 组件测试:mock 网络,不 mock 子组件
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
/**
* 用 MSW 在网络层拦截,而不是 mock 掉 fetch 或整个模块。
* 好处:组件内部的请求逻辑、错误处理、加载态都能被真实覆盖。
*/
const server = setupServer(
http.get('/api/orders', ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page') ?? 1);
if (page === 1) {
return HttpResponse.json({ list: [{ id: '1', title: '订单 A' }], total: 2 });
}
return HttpResponse.json({ list: [{ id: '2', title: '订单 B' }], total: 2 });
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('加载更多会追加数据', async () => {
const user = userEvent.setup();
render(<OrderList />);
expect(await screen.findByText('订单 A')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '加载更多' }));
expect(await screen.findByText('订单 B')).toBeInTheDocument();
});
mock 的层次:
| 层次 | 做法 | 评价 |
|---|---|---|
| mock 模块 | vi.mock('./api') | 快,但绕过真实逻辑 |
| mock 网络 | MSW | 推荐,接近真实 |
| mock 服务 | 本地假服务/容器 | 重,但最真 |
2.3 异步测试:不要用固定等待
// ❌ 等 1 秒:慢且不稳定
await new Promise((r) => setTimeout(r, 1000));
// ✅ 等「条件成立」
await waitFor(() => expect(screen.getByText('保存成功')).toBeInTheDocument());
// ✅ 等元素出现
expect(await screen.findByRole('alert')).toHaveTextContent('保存失败');
原则:测试等待的是可观察条件,不是时间。固定 sleep 是 flaky 的头号来源。
2.4 E2E:Playwright 的自动等待
import { test, expect } from '@playwright/test';
test('用户下单成功', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('账号').fill('demo');
await page.getByLabel('密码').fill('123456');
await page.getByRole('button', { name: '登录' }).click();
await expect(page.getByRole('heading', { name: '商品列表' })).toBeVisible();
await page.getByRole('link', { name: '商品 A' }).click();
await page.getByRole('button', { name: '加入购物车' }).click();
await page.getByRole('link', { name: '去结算' }).click();
await expect(page.getByText('下单成功')).toBeVisible();
await expect(page).toHaveURL(/\/orders\/\d+/);
});
E2E 的三条纪律:
- 只用用户可见的定位器(Role/Label/Text),不用 CSS 路径;
- 不写固定
sleep,用expect(...).toBeVisible()自动重试; - 每个用例独立:独立账号、独立数据、可并行。
2.5 视觉回归
test('订单卡片外观稳定', async ({ page }) => {
await page.goto('/order/1');
await expect(page.getByTestId('order-card')).toHaveScreenshot('order-card.png', {
maxDiffPixelRatio: 0.01, // 允许 1% 像素差异,吸收字体渲染差异
});
});
适用:设计系统组件、营销页、复杂表格。 不适用:内容频繁变化、动画中、随机数据(先固定数据与时钟)。
2.6 契约测试
/**
* 用 OpenAPI schema 校验真实响应,防止后端悄悄改字段。
* 比 mock 更强:mock 只能证明「前端能处理假数据」。
*/
test('GET /orders 符合契约', async ({ request }) => {
const res = await request.get('/api/orders');
expect(res.status()).toBe(200);
const body = await res.json();
expect(body).toMatchSchema(orderListSchema); // zod / ajv
});
触发时机:后端发布前跑一遍,前端 CI 也跑一遍。
2.7 测试数据:工厂函数优于固定 JSON
/**
* 工厂函数:只写关心的字段,其余给合理默认值。
* 好处:新增字段不会让所有用例失效。
*/
function makeOrder(overrides = {}) {
return {
id: 'o_1',
title: '测试订单',
status: 'created',
total: 100,
createdAt: '2026-01-01T00:00:00Z',
...overrides,
};
}
const paidOrder = makeOrder({ status: 'paid' });
2.8 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| 分层 | 组件级集成性价比最高 |
| 网络 | MSW 优于模块 mock |
| 异步 | 等条件,不等时间 |
| E2E | 用户可见定位器 + 自动等待 + 独立数据 |
| 视觉 | 固定数据与时钟后再比图 |
| 数据 | 工厂函数,避免脆弱夹具 |
自检题
- 为什么推荐 MSW 而不是
vi.mock('./api')?——MSW 在 HTTP 边界拦截,组件与请求逻辑真实执行,能覆盖错误码、重试与加载态;模块 mock 会把实现一起替换掉。 - E2E 用例之间共享登录态有什么风险?——用例相互影响、并行冲突、状态污染导致 flaky;应每个 worker 独立账号,或用
storageState做隔离。 - 契约测试能替代 E2E 吗?——不能。它保证接口形状兼容,不覆盖真实交互、渲染与完整用户流程。
3. L3 高级:可测试性、flaky 与 CI 策略
3.1 可测试性是设计出来的
// ❌ 直接依赖全局时间:测试无法控制
function isExpired(token) {
return Date.now() > token.exp;
}
// ✅ 注入依赖:测试可以传任意时间
/**
* @param {{ exp: number }} token
* @param {number} [now] 当前时间戳(毫秒)
*/
function isExpired(token, now = Date.now()) {
return now > token.exp;
}
三种常见「不可测」及解法:
| 不可测来源 | 解法 |
|---|---|
| 时间 | 注入 now,或用 vi.setSystemTime |
| 随机 | 注入 random,或 mock crypto.randomUUID |
| 网络/IO | 依赖注入 + MSW;把副作用推到边界 |
架构含义:可测试性差的代码,通常也是耦合高的代码。测试写不动,往往是设计信号。
3.2 flaky 测试治理
| 原因 | 表现 | 修法 |
|---|---|---|
| 固定等待 | 偶发超时 | 改条件等待 |
| 共享状态 | 单跑过、全跑挂 | 用例隔离、重置 store/DB |
| 时序竞争 | 快速机器过、慢机器挂 | 等待可观察状态 |
| 动画未结束 | 点击落空 | 禁用动画或等待稳定 |
| 外部依赖 | 第三方接口抖动 | 本地 mock 或录制回放 |
| 端口/资源冲突 | 并行时挂 | 动态端口、独立数据 |
// Playwright:先禁用动画,减少不确定性
test.use({ launchOptions: { args: ['--force-prefers-reduced-motion'] } });
治理流程:
- 标记 flaky(自动重试一次,仍失败则记录);
- 统计「哪些用例最常 flaky」;
- 修复或删除(长期 flaky 的测试比没有更糟,会让人忽略红灯)。
3.3 并行、分片与速度
# Playwright 分片:4 台机器各跑 1/4
npx playwright test --shard=1/4
| 手段 | 收益 | 注意 |
|---|---|---|
| 并行用例 | 线性提速 | 必须隔离数据 |
| 分片 | 多机扩展 | 需要 CI 编排 |
| 只跑受影响用例 | 大幅减少 | 依赖准确的依赖图 |
| 缓存依赖 | 减少安装时间 | lockfile key |
只跑受影响用例(test impact analysis):根据 git diff 找到受影响的模块与测试。收益大,但依赖图不准时会漏测,建议用于 PR、全量用于主干。
3.4 性能测试与可访问性测试
// 用 Playwright 采集一次交互的性能
test('搜索交互性能', async ({ page }) => {
await page.goto('/search');
const client = await page.context().newCDPSession(page);
await client.send('Performance.enable');
const start = Date.now();
await page.getByRole('searchbox').fill('手机');
await page.getByRole('button', { name: '查询' }).click();
await expect(page.getByText('共 3 条结果')).toBeVisible();
expect(Date.now() - start).toBeLessThan(1000);
});
// 可访问性:自动扫描 + 人工复核
import AxeBuilder from '@axe-core/playwright';
test('首页无严重可访问性问题', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations.filter((v) => v.impact === 'critical')).toHaveLength(0);
});
自动扫描能覆盖约 30%–50% 的无障碍问题,键盘流程与读屏体验仍需人工。
3.5 变异测试:检验「测试本身有效吗」
思路:故意改坏源码(把 > 改 >=、删掉一行),看测试是否会失败。测试没失败 = 测试无效。
| 指标 | 含义 |
|---|---|
| Mutation Score | 被杀死的变异比例 |
| Survived | 存活变异(测试盲区) |
适用:核心算法、金额计算、权限判断。不适合全量跑(成本高)。
3.6 CI 门禁设计
jobs:
test:
steps:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test -- --coverage --changed
- run: pnpm exec playwright test --shard=${{ matrix.shard }}/4
| 门禁 | 阻断条件 | 理由 |
|---|---|---|
| 类型检查 | 有错误 | 最便宜的有效检查 |
| Lint | 有 error | 统一风格与危险 API |
| 单测 | 失败或核心模块覆盖率下降 | 回归防线 |
| E2E | 关键流程失败 | 端到端保障 |
| 契约 | schema 不兼容 | 跨团队协作 |
| 包体 | 超预算 | 性能防回退 |
分级策略:PR 跑快速集(< 10min),主干跑全量,发布前跑冒烟。
3.7 测试架构:fixtures 与分层目录
tests/
├─ unit/ # 纯逻辑,毫秒级
├─ components/ # 组件 + MSW
├─ e2e/ # Playwright
├─ fixtures/ # 数据工厂、登录态
├─ mocks/ # MSW handlers
└─ utils/ # 自定义 render、等待工具
/**
* 自定义 render:统一注入 Provider,避免每个用例重复包裹。
*/
function renderWithProviders(ui, { preloadedState } = {}) {
return render(
<Provider store={createTestStore(preloadedState)}>
<Router>{ui}</Router>
</Provider>,
);
}
3.8 L3 小结与自检
| 主题 | 关键结论 |
|---|---|
| 可测试性 | 时间/随机/IO 都要能注入 |
| flaky | 等条件、隔数据、禁动画;长期 flaky 就删 |
| 速度 | 并行 + 分片 + 受影响用例 |
| 变异测试 | 检验测试是否真的有效 |
| 门禁 | 快慢分层,PR 快速、主干全量 |
自检题
- 一个用例单跑通过、全量失败,通常是什么问题?——共享状态或数据污染、执行顺序依赖、并行端口/账号冲突、全局 mock 未重置。
- 为什么说「长期 flaky 的测试比没有更糟」?——它训练团队忽略失败,掩盖真实缺陷,让门禁失去可信度;必须隔离并限期修复。
- 变异测试解决了覆盖率的什么盲区?——覆盖率只说明执行过;变异测试在代码里注入错误,检验断言能否发现,暴露「跑到但没断言」的测试。
4. L4 专家:质量体系与成本
4.1 质量度量:少而准
| 指标 | 含义 | 陷阱 |
|---|---|---|
| 逃逸缺陷率 | 上线后发现的问题 / 总问题 | 依赖缺陷统计口径 |
| 变更失败率 | 发布导致故障的比例 | 需要与发布系统打通 |
| MTTR | 平均恢复时间 | 受流程影响大 |
| 测试稳定性 | 非代码原因失败比例 | flaky 的直接度量 |
| 反馈时长 | 提交到拿到结果 | 影响开发节奏 |
| 覆盖率变化 | 增量覆盖率 | 比总量更有意义 |
建议:只盯 3–5 个,且每个都有负责人。指标太多等于没有指标。
4.2 测试成本与 ROI
成本 = 编写时间 + 维护时间 + 运行时间 + flaky 处理
收益 = 减少的线上故障 × 故障成本 + 提升的迭代速度
| 场景 | 值得投入 | 不值得 |
|---|---|---|
| 金额计算、权限 | 单元 + 变异测试 | — |
| 核心下单流程 | E2E + 契约 | — |
| 一次性活动页 | 冒烟即可 | 全套 E2E |
| 频繁改版的 UI | 行为测试 | 像素级视觉回归 |
| 内部工具 | 关键路径 | 高覆盖率 |
关键判断:测试也要算维护成本。一个每月要修 5 次的用例,不如删掉重写。
4.3 质量左移
| 阶段 | 动作 |
|---|---|
| 需求 | 明确验收标准(可测试) |
| 设计 | 可测试性评审、错误路径梳理 |
| 编码 | TDD/测试同提交、lint 规则 |
| 提交 | 增量测试、类型检查 |
| 构建 | 契约、包体、a11y |
| 发布 | 灰度、冒烟、回滚预案 |
| 运行 | 监控、告警、事故转用例 |
最有效的一步:把每次线上事故转成一条自动化用例。这比任何覆盖率目标都有效。
4.4 组织与文化
| 问题 | 表现 | 对策 |
|---|---|---|
| 测试是「测试同学的事」 | 开发不写用例 | 定义「完成的定义」包含测试 |
| 红灯没人管 | CI 长期失败 | 主干红灯即停线 |
| 追求覆盖率 KPI | 写无断言测试 | 改为「关键路径 + 变异分数」 |
| 没有 owner | 用例腐烂 | 按模块指定 owner |
4.5 事故复盘如何转化为质量资产
事故:优惠券叠加导致金额为负
↓
根因:缺少边界用例;金额计算是纯函数但没测负数
↓
转化:
1) 补单元测试(负数、0、极大值)
2) 加类型约束(金额用分,禁止浮点)
3) 加监控告警(负金额出现即报警)
4) 复盘文档进知识库
4.6 L4 自检
- 团队 CI 要 40 分钟,如何在不降低保障的前提下缩短?——按影响面只跑相关用例、分片并行、缓存依赖与构建,PR 跑冒烟、合并后跑全量。
- 如何说服业务为「看不见的测试」投入人力?——用事故成本(资损、工时、口碑)与回归缺陷率对比测试投入,先做一个模块的试点数据。
- 一个模块测试全烂了,你会重写还是删除?——先看业务价值与变更频率:核心且长期维护的重写,边缘或待下线的删除并补监控,不要维护假测试。
5. 综合实战:下单流程的测试策略
需求:选商品 → 填地址 → 用优惠券 → 支付 → 成功页。
步骤 1 · 拆解风险
| 环节 | 风险 | 测试层级 |
|---|---|---|
| 金额计算 | 优惠叠加、负数、精度 | 单元 + 变异 |
| 地址表单 | 校验、联动 | 组件 |
| 优惠券 | 过期、不可用、互斥 | 单元 + 组件 |
| 下单接口 | 幂等、库存不足、超时 | 契约 + 集成 |
| 支付跳转 | 回跳、状态轮询 | E2E |
| 成功页 | 重复提交、刷新 | E2E + 单元 |
步骤 2 · 关键用例
test('优惠券叠加后金额不为负', () => {
const cart = makeCart({ total: 100 });
const coupons = [makeCoupon({ type: 'minus', value: 80 }), makeCoupon({ type: 'minus', value: 50 })];
const result = calcTotal(cart, coupons);
expect(result.total).toBe(0); // 不允许负数
expect(result.discount).toBe(100); // 折扣不超过总额
});
test('重复点击提交只创建一个订单', async ({ page }) => {
await page.goto('/checkout');
const submit = page.getByRole('button', { name: '提交订单' });
await Promise.all([submit.click(), submit.click()]); // 模拟连点
await expect(page.getByText('下单成功')).toBeVisible();
const count = await page.evaluate(() => window.__orders.length);
expect(count).toBe(1);
});
步骤 3 · CI 分层
PR:单元 + 组件 + 类型 + lint(< 8min)
主干:加契约 + 关键 E2E(< 15min)
发布前:全量 E2E + 视觉回归 + 冒烟(< 30min)
步骤 4 · 验收清单
- 金额边界(0、负数、极大值、浮点)全覆盖
- 连点提交只产生一单
- 库存不足/支付失败有明确 UI
- 回跳后状态正确(不重复下单)
- 关键 E2E 连续 50 次无 flaky
- 事故用例已沉淀为回归测试
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
| 「覆盖率越高越好」 | 执行不等于断言,关键路径优先 |
| 「E2E 最真实,全都写 E2E」 | 慢、贵、脆,定位困难 |
| 「测试要 mock 所有依赖」 | 过度 mock 会让测试与真实行为脱节 |
| 「flaky 重跑几次就行」 | 会训练团队忽略红灯 |
| 「测试是测试同学的事」 | 谁写代码谁写测试 |
| 「快照测试能替代断言」 | 快照只证明「和上次一样」 |
| 「视觉回归能发现所有 UI 问题」 | 只覆盖渲染,交互与语义需另测 |
| 「覆盖率下降一定要阻断」 | 看是否关键模块;避免为指标写垃圾测试 |
| 「测试写完就不用管」 | 用例会腐烂,需要 owner 与清理 |
| 「线上监控能替代测试」 | 监控是发现,测试是预防,成本不同 |
7. 面试高频题(附答题思路)
Q1:测试金字塔是什么?前端为什么常用奖杯模型? 分层与成本 → 前端价值在组合行为 → 组件级集成性价比最高 → 举例。
Q2:单元测试和集成测试的边界? 是否跨模块/是否渲染/是否触网;给具体判断标准;再说过度 mock 的代价。
Q3:如何治理 flaky 测试? 分类原因(等待/共享状态/时序/外部依赖)→ 条件等待与隔离 → 统计与标记 → 长期 flaky 删除。
Q4:覆盖率有意义吗? 有意义但有限:执行≠断言;用增量覆盖率 + 关键路径 + 变异测试补充。
Q5:E2E 怎么写才稳定? 语义定位器、自动等待、独立数据、禁动画、并行隔离、分片。
Q6:如何测试异步与网络?
MSW 在网络层拦截 → findBy/waitFor 等条件 → 覆盖加载/错误/空态。
Q7:CI 太慢怎么办? 分层(PR/主干/发布)→ 只跑受影响用例 → 并行分片 → 缓存 → 去掉低价值用例。
Q8:如何证明测试投入的价值? 逃逸缺陷率、变更失败率、MTTR、反馈时长;用事故转用例的故事说明。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 测试分层 | 每层测什么、比例依据 | 按「变更频率 × 失败成本」分配 | 「覆盖率越高越好」 |
| 断言质量 | 测行为还是实现 | 从用户视角查询与断言 | 断言内部 state 与类名 |
| 异步处理 | 是否会等待「条件」 | 用 findBy / waitFor | sleep(1000) |
| 稳定性 | flaky 的根因治理 | 隔离数据、固定时钟、显式等待 | 失败就重跑 |
| 成本意识 | 测试的维护成本 | 少而准,关键路径必测 | 什么都写 E2E |
8.2 追问链
链 1 · 测试金字塔 → ROI → 反模式
- 面试官:为什么单元测试要多、E2E 要少?
- 要点:成本与速度的权衡。单元测试毫秒级、定位精确;E2E 秒级、易受环境影响、失败定位成本高。金字塔不是硬性比例,而是「越靠近用户,越只测关键路径」。
- 追问:覆盖率 80% 说明质量好吗?
- 要点:只说明执行到的代码比例,不说明断言是否有效。分支覆盖比行覆盖更有意义;关键模块可用变异测试检验「测试能不能发现错误」。
- 再追问:哪些测试是反模式?
- 要点:断言实现细节(内部 state、类名、调用次数)、过度快照(一改样式就红)、测试之间共享可变状态、为覆盖率而写的无断言测试。
链 2 · 组件测试 → 查询优先级 → mock 边界
- 面试官:Testing Library 为什么提倡
getByRole?- 要点:它按可访问性树查询,和用户/读屏的使用方式一致;
getByTestId是最后手段,因为它在语义上不承载任何信息。
- 要点:它按可访问性树查询,和用户/读屏的使用方式一致;
- 追问:
userEvent与fireEvent差别在哪?- 要点:
userEvent模拟完整交互序列(pointerdown、focus、keydown、input、click),会触发真实副作用;fireEvent只派发单个事件,容易漏掉中间态。
- 要点:
- 再追问:网络该 mock 到哪一层?
- 要点:mock 在 HTTP 边界(MSW),组件与 hooks 跑真实逻辑,避免「mock 了 fetch 却测不到错误处理」;不要 mock 子组件,否则测的是拼装而不是行为。
链 3 · E2E 稳定性 → 选择器 → 并行隔离
- 面试官:E2E 为什么会 flaky?
- 要点:固定
sleep、依赖共享账号与数据、动画未结束、网络时序、测试顺序耦合。根因是「等待了时间而不是等待了条件」。
- 要点:固定
- 追问:选择器该怎么选?
- 要点:优先
getByRole+ 可访问名称,其次稳定的data-testid;避免 CSS 类名与 DOM 结构路径,它们会随样式重构失效。
- 要点:优先
- 再追问:并行执行要注意什么?
- 要点:每个 worker 独立账号与数据命名空间;避免操作同一库存/订单;用 API 预置数据而不是走 UI;数据库清理按命名空间而非全表。
8.3 手写题
题 1 · 极简测试框架
/** 已注册的用例 */
const suites = [];
let currentSuite = null;
/**
* 定义一组用例。
* @param {string} name
* @param {() => void} fn
*/
function describe(name, fn) {
const parent = currentSuite;
currentSuite = { name, tests: [], children: [], parent };
(parent ? parent.children : suites).push(currentSuite);
fn();
currentSuite = parent;
}
/**
* 定义单个用例。
* @param {string} name
* @param {() => void | Promise<void>} fn
*/
function it(name, fn) {
currentSuite.tests.push({ name, fn });
}
/**
* 断言:只实现最常用的相等与抛错。
* @param {any} actual
*/
function expect(actual) {
return {
toBe(expected) {
if (!Object.is(actual, expected)) {
throw new Error(`期望 ${String(expected)},实际 ${String(actual)}`);
}
},
toEqual(expected) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`);
}
},
toThrow() {
let thrown = false;
try {
actual();
} catch {
thrown = true;
}
if (!thrown) throw new Error('期望抛出异常,但没有');
},
};
}
/**
* 执行全部用例,返回失败列表。
* @returns {Promise<Array<{name: string, error: Error}>>}
*/
async function run() {
const failures = [];
const visit = async (suite, prefix) => {
for (const test of suite.tests) {
const fullName = `${prefix}${suite.name} > ${test.name}`;
try {
await test.fn();
console.log(`✓ ${fullName}`);
} catch (error) {
failures.push({ name: fullName, error });
console.error(`✗ ${fullName}`);
}
}
for (const child of suite.children) await visit(child, `${prefix}${suite.name} > `);
};
for (const suite of suites) await visit(suite, '');
return failures;
}
追问:
- 为什么要支持 async 用例?→ 组件渲染与请求都是异步的,不支持
await只能靠回调,错误无法被捕获。 beforeEach该怎么加?→ 在 suite 上挂钩子,visit执行每个用例前按父链顺序调用。toEqual用JSON.stringify有什么问题?→ 键顺序、undefined、Date、循环引用都会误判,生产实现需要深比较。
题 2 · 假定时器
/**
* 假定时器:接管 setTimeout/setInterval,支持推进与全部执行。
* @param {number} [start=0] 起始时间
*/
function createFakeTimers(start = 0) {
let now = start;
let seq = 0;
const timers = new Map();
const schedule = (fn, delay = 0, interval = null, args = []) => {
const id = ++seq;
timers.set(id, { fn, time: now + Math.max(0, delay), interval, args });
return id;
};
const clear = (id) => timers.delete(id);
/** 取出下一个到期任务执行 */
const tick = () => {
let next = null;
timers.forEach((timer, id) => {
if (!next || timer.time < next.timer.time) next = { id, timer };
});
if (!next) return false;
const { id, timer } = next;
now = timer.time;
if (timer.interval == null) timers.delete(id);
else timer.time = now + timer.interval;
timer.fn(...timer.args);
return true;
};
/**
* 推进指定毫秒,执行到期任务(含推进过程中新增的)。
* @param {number} ms
*/
const advance = (ms) => {
const target = now + ms;
while (true) {
let next = null;
timers.forEach((timer, id) => {
if (timer.time <= target && (!next || timer.time < next.timer.time)) next = { id, timer };
});
if (!next) break;
tick();
}
now = target;
};
const runAll = () => {
while (tick()) {
// 直到没有任务,注意 setInterval 会死循环,需加保护
}
};
return {
setTimeout: (fn, delay, ...args) => schedule(fn, delay, null, args),
setInterval: (fn, delay, ...args) => schedule(fn, delay, delay, args),
clearTimeout: clear,
clearInterval: clear,
advance,
runAll,
get now() {
return now;
},
};
}
追问:
- 为什么
runAll遇到setInterval会死循环?→ 间隔任务会不断重新入队,需要设最大迭代次数或只推进固定时长。 - 微任务怎么处理?→ 假定时器只接管宏任务,
await Promise.resolve()仍走真实微任务,测试中要await让微任务清空。 - 和
jest.useFakeTimers()的差异?→ 生产实现还要接管Date、performance.now、queueMicrotask等,保证时间完全可控。
8.4 场景设计题
题:团队没有测试,线上每个月出两次事故。你只有两个月、两个人,怎么建立质量防线?
评估点:是否聚焦高风险路径、是否让测试可持续。
参考方案:
- 找高风险:按「事故历史 + 变更频率 + 资损影响」选出 3 条核心链路(登录、下单、支付)。
- 先补 E2E:核心链路用 Playwright 覆盖,稳定优先于全面;每条约 5 分钟,跑在合并前。
- 再补单元:把纯逻辑(金额、校验、状态机)抽出来测,成本低、回归价值高。
- 门禁:增量覆盖率 + 核心用例必过;失败不允许合入;flaky 用例 24 小时内修复或隔离。
- 运营:每月看「逃逸缺陷数」而不是总覆盖率;把事故复盘转成回归用例。
加分:指出「先写测试再补监控」的顺序问题,事故定位能力与测试同等重要;指出要给测试代码设 owner 与时限。
减分:一上来要求 80% 覆盖率;全部写 E2E;用重跑掩盖 flaky。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
| 「覆盖率越高质量越好」 | 覆盖率只反映执行范围,不反映断言有效性 |
| 「E2E 最接近用户所以要最多」 | 成本高、稳定性差,只覆盖关键路径 |
| 「快照测试能防 UI 回归」 | 大快照噪声大,容易「红了一起改」而失去意义 |
「fireEvent 和 userEvent 等价」 | 后者模拟完整交互序列,更接近真实行为 |
| 「mock 越多测试越独立」 | 过度 mock 会测不到集成问题,应在边界 mock |
| 「flaky 重跑就好」 | 重跑掩盖真实竞态与数据污染 |
| 「测试要断言内部状态」 | 内部实现会变,应断言用户可见行为 |
| 「测试是 QA 的事」 | 单测与组件测试由开发负责,QA 聚焦端到端与探索性测试 |
9. 延伸资源
- Vitest / Jest 官方文档(配置、mock、覆盖率)
- Testing Library 文档:查询优先级、
user-event、常见错误 - Playwright 文档:定位器、自动等待、分片、视觉对比
- MSW 文档:网络层 mock 与 handlers 复用
- Pact / OpenAPI + zod:契约测试
- Stryker(变异测试)文档
- axe-core / Lighthouse CI:可访问性与质量门禁
- Google Testing Blog 与《Unit Testing: Principles, Practices, and Patterns》