13

测试与质量保障

974 行约 42 分钟28 个代码块24 张表格更新于 2026-11-03

13 · 测试与质量保障(详解版)

一句话定位:测试不是「证明代码对」,而是用可重复的成本,换取「改动不怕」的信心。质量保障则是把这种信心变成流程与门禁。

读完本篇你应该能做到

  1. 为一个功能选择合适的测试层级,而不是「全都写 E2E」;
  2. 用 Testing Library + MSW 写出稳定、面向行为的组件测试;
  3. 治理 flaky 测试、设计 CI 门禁、读懂覆盖率背后的假象;
  4. 建立团队级质量体系,把事故转化为可回归的用例。

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

覆盖率的三个假象:

  1. 执行 ≠ 断言:代码跑过但没有 expect,覆盖率 100% 也没用;
  2. 测试实现细节:覆盖率很高,但重构就挂、真实行为没测;
  3. 关键路径没覆盖:支付失败、网络异常、权限不足这些分支往往最缺。

1.6 L1 小结与自检

主题关键结论
结构Arrange / Act / Assert
断言测行为,不测内部状态
查询按用户视角,Role 优先
时机get 立刻、query 断言不存在、find 异步
覆盖率是参考,不是目标

自检题

  1. 为什么 getByTestId 应该最后才用?——它不表达用户可见语义,测试与实现耦合;优先用 role、label、可见文本查询,测行为而不是标记。
  2. 覆盖率 100% 为什么仍可能有严重 bug?——覆盖只证明代码被执行过,不证明断言正确;分支组合、异常路径、集成问题与需求理解错误都不在覆盖里。
  3. 断言「不存在」为什么必须用 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 的三条纪律:

  1. 只用用户可见的定位器(Role/Label/Text),不用 CSS 路径;
  2. 不写固定 sleep,用 expect(...).toBeVisible() 自动重试;
  3. 每个用例独立:独立账号、独立数据、可并行。

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用户可见定位器 + 自动等待 + 独立数据
视觉固定数据与时钟后再比图
数据工厂函数,避免脆弱夹具

自检题

  1. 为什么推荐 MSW 而不是 vi.mock('./api')?——MSW 在 HTTP 边界拦截,组件与请求逻辑真实执行,能覆盖错误码、重试与加载态;模块 mock 会把实现一起替换掉。
  2. E2E 用例之间共享登录态有什么风险?——用例相互影响、并行冲突、状态污染导致 flaky;应每个 worker 独立账号,或用 storageState 做隔离。
  3. 契约测试能替代 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'] } });

治理流程:

  1. 标记 flaky(自动重试一次,仍失败则记录);
  2. 统计「哪些用例最常 flaky」;
  3. 修复或删除(长期 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 快速、主干全量

自检题

  1. 一个用例单跑通过、全量失败,通常是什么问题?——共享状态或数据污染、执行顺序依赖、并行端口/账号冲突、全局 mock 未重置。
  2. 为什么说「长期 flaky 的测试比没有更糟」?——它训练团队忽略失败,掩盖真实缺陷,让门禁失去可信度;必须隔离并限期修复。
  3. 变异测试解决了覆盖率的什么盲区?——覆盖率只说明执行过;变异测试在代码里注入错误,检验断言能否发现,暴露「跑到但没断言」的测试。

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 自检

  1. 团队 CI 要 40 分钟,如何在不降低保障的前提下缩短?——按影响面只跑相关用例、分片并行、缓存依赖与构建,PR 跑冒烟、合并后跑全量。
  2. 如何说服业务为「看不见的测试」投入人力?——用事故成本(资损、工时、口碑)与回归缺陷率对比测试投入,先做一个模块的试点数据。
  3. 一个模块测试全烂了,你会重写还是删除?——先看业务价值与变更频率:核心且长期维护的重写,边缘或待下线的删除并补监控,不要维护假测试。

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 / waitForsleep(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 场景设计题

题:团队没有测试,线上每个月出两次事故。你只有两个月、两个人,怎么建立质量防线?

评估点:是否聚焦高风险路径、是否让测试可持续。

参考方案:

  1. 找高风险:按「事故历史 + 变更频率 + 资损影响」选出 3 条核心链路(登录、下单、支付)。
  2. 先补 E2E:核心链路用 Playwright 覆盖,稳定优先于全面;每条约 5 分钟,跑在合并前。
  3. 再补单元:把纯逻辑(金额、校验、状态机)抽出来测,成本低、回归价值高。
  4. 门禁:增量覆盖率 + 核心用例必过;失败不允许合入;flaky 用例 24 小时内修复或隔离。
  5. 运营:每月看「逃逸缺陷数」而不是总覆盖率;把事故复盘转成回归用例。

加分:指出「先写测试再补监控」的顺序问题,事故定位能力与测试同等重要;指出要给测试代码设 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》