组件测试不是"测试组件",而是"从用户视角测试界面行为"。 很多团队写出的组件测试,断言的是 props 传没传、state 变没变、某个 CSS 类名在不在——这类测试会在重构时大面积破碎,却抓不住真正的回归。本文要解决的核心问题是:如何用 Testing Library 的可访问性查询、Storybook 的 play 函数、MSW 的网络拦截,写出只关心用户看得见、点得到、读得懂的东西的组件测试,让测试成为重构的安全网而不是绊脚石。
一、组件测试在测试金字塔中的位置
1.1 三层前端测试的职责划分
单元测试(纯函数 / hooks / composables) 无 DOM,毫秒级
· 覆盖格式化、计算、状态机逻辑 · 占比目标:60%
组件测试(单个组件 / 组件组合) 真实 DOM,秒级
· 覆盖交互、条件渲染、表单校验、错误态 · 占比目标:30%
E2E 测试(真实浏览器 + 真实后端) 分钟级,最贵最脆弱
· 只覆盖关键用户旅程 · 占比目标:10%
1.2 组件测试该断言什么
| 断言对象 | 是否推荐 | 原因 |
|---|---|---|
| 用户可见文本 | 推荐 | 最稳定、最贴近真实体验 |
| ARIA role / label | 强烈推荐 | 顺带验证可访问性 |
| 可见性(是否渲染) | 推荐 | 条件渲染的核心行为 |
| 事件回调被调用 | 推荐 | 交互契约 |
| 组件内部 state | 不推荐 | 实现细节,重构即碎 |
| CSS 类名 / 样式 | 谨慎 | 除非类名是公开契约 |
| 快照整体对比 | 谨慎 | 易被无脑更新掩盖问题 |
一句话:判断一条断言好不好,问自己"如果我把组件内部实现重写一遍但行为不变,这条断言会碎吗?"——会碎的就是坏断言。
二、Testing Library 的查询哲学
2.1 查询优先级:越靠前越推荐
优先使用(可访问性导向):
1. getByRole ← 首选,天然带可访问性校验
2. getByLabelText ← 表单元素首选 3. getByPlaceholderText
4. getByText ← 非表单内容首选 5. getByDisplayValue
次选(语义弱):getByAltText / getByTitle
最后手段:getByTestId ← 需团队评审
禁止:container.querySelector('.btn-primary')、依赖 DOM 结构与类名
为什么 getByRole 最强?它要求元素有正确的语义角色——button 必须是
<button> 或 role="button"。写测试时顺手就发现了可访问性缺陷。
2.2 一个符合哲学的组件测试
// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SubmitButton } from './SubmitButton';
describe('SubmitButton', () => {
it('渲染可访问的按钮并响应点击', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<SubmitButton onClick={onClick}>提交订单</SubmitButton>);
// 1. 用 role 查询,顺带验证语义正确
const button = screen.getByRole('button', { name: '提交订单' });
expect(button).toBeEnabled();
// 2. 用真实用户事件而非 fireEvent
await user.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('加载中禁用按钮并暴露 aria-busy', () => {
render(<SubmitButton loading>提交订单</SubmitButton>);
const button = screen.getByRole('button', { name: '提交订单' });
expect(button).toBeDisabled();
expect(button).toHaveAttribute('aria-busy', 'true');
});
});
一句话:
getByRole('button', { name })一行断言同时验证了"语义正确 + 文案正确 + 可交互",比querySelector的收益高一个量级。
2.3 查询变体:get / query / find
| 变体 | 找不到时 | 何时用 |
|---|---|---|
getBy* | 抛异常 | 元素必须存在 |
queryBy* | 返回 null | 断言元素不存在 |
findBy* | 返回 rejected Promise | 元素异步出现 |
getAllBy* / queryAllBy* | 抛异常 / 空数组 | 断言数量 / “一个都没有” |
// 反例:用 getBy 断言不存在 → 测试会红
expect(() => screen.getByText('错误提示')).toThrow(); // ✗ 脆弱
// 正例:用 queryBy 断言不存在
expect(screen.queryByText('错误提示')).not.toBeInTheDocument(); // ✓
// 正例:异步出现的元素用 findBy
expect(await screen.findByRole('alert')).toHaveTextContent('保存成功'); // ✓
三、用户事件与异步断言
3.1 userEvent 与 fireEvent 的区别
fireEvent.click(el):直接派发一个 click 事件,不模拟真实交互序列,
不触发 focus、pointerdown、mousedown、mouseup
userEvent.click(el):模拟完整交互序列 pointerover → pointerdown →
mousedown → focus → pointerup → mouseup → click,并检查元素是否
可交互(pointer-events、disabled、可见性),默认带 delay。
结论:一律用 userEvent,fireEvent 只在极少数底层场景使用。
3.2 异步断言的正确姿势
// SearchBox.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBox } from './SearchBox';
it('输入关键词后展示搜索结果', async () => {
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'playwright');
// ✓ 用 findBy 等待元素出现(内部就是 waitFor + getBy)
const results = await screen.findAllByRole('listitem');
expect(results).toHaveLength(3);
expect(results[0]).toHaveTextContent('Playwright 入门');
});
it('请求失败时展示错误提示', async () => {
server.use(
http.get('/api/search', () => HttpResponse.json({ message: 'boom' }, { status: 500 })),
);
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'x');
// ✓ 用 findByRole 等待 alert(ARIA 语义 + 异步)
expect(await screen.findByRole('alert')).toHaveTextContent('搜索失败,请重试');
});
3.3 waitFor 的三个反模式
反模式一:waitFor 里放空断言 await waitFor(() => {}) ✗ 必然立即通过
反模式二:waitFor 里做副作用 await waitFor(() => { fetchData(); })
✗ 会重试多次,副作用爆炸 → 副作用放外面,里面只做断言
反模式三:加大 timeout 掩盖 flaky { timeout: 10000 } ✗ 只是变成长等待
正解:优先 findBy*(语义清晰、内置等待),
只有多元素条件组合时才用 waitFor。
一句话:
waitFor是逃生舱,findBy*才是日常工具——能用findBy就别用waitFor。
四、Storybook 交互测试与 play 函数
4.1 play 函数:把交互写进 Story
// Form.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within, fn } from '@storybook/test';
import { LoginForm } from './LoginForm';
const meta = {
component: LoginForm,
args: { onSubmit: fn() },
} satisfies Meta<typeof LoginForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const 提交成功: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText('邮箱'), 'dev@example.com');
await userEvent.type(canvas.getByLabelText('密码'), 's3cret!');
await userEvent.click(canvas.getByRole('button', { name: '登录' }));
await expect(args.onSubmit).toHaveBeenCalledWith({
email: 'dev@example.com',
password: 's3cret!',
});
},
};
export const 校验失败: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button', { name: '登录' }));
await expect(canvas.getByText('请输入邮箱')).toBeInTheDocument();
},
};
4.2 在 CI 中运行 Storybook 测试
# 方式一:test-runner(Playwright 真实浏览器)启动 Storybook 逐 story 执行 play
npx storybook test --ci --coverage
# 方式二:Vitest addon(jsdom/browser,更快)
npx vitest --project=storybook
# 方式三:只跑变更 story(增量)
npx storybook test --ci --onlyChanged
| 方案 | 运行环境 | 速度 | 能力 |
|---|---|---|---|
| test-runner | 真实浏览器 | 慢 | 真实布局、截图、跨浏览器 |
| Vitest addon | jsdom / browser mode | 快 | 逻辑交互,无真实布局 |
| Chromatic | 云端浏览器 | 中 | 视觉回归 + 交互 + 评审流 |
一句话:Storybook 的最大价值是让"组件状态"成为可枚举、可交互、可测试的一等公民——每个 Story 就是一个自动维护的测试用例。
五、MSW:网络层 Mock 的集成
5.1 为什么在网络层 Mock 而不是 Mock fetch
Mock 模块(vi.mock('./api'))
✗ 与实现强耦合,改函数名就碎;无法验证真实的请求参数与序列化
Mock fetch / axios
✗ 需 mock 全局,影响其他测试;拦截不到真实请求
MSW(Service Worker / Node 拦截器)
✓ 拦截在网络层,代码调用真实 fetch/axios
✓ 请求 URL、方法、body、headers 全部可断言
✓ 同一份 handler 可用于测试、Storybook、本地开发
✓ 未处理的请求可配置为报错,避免"假通过"
5.2 MSW 完整配置
// mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw';
export const handlers = [
http.get('/api/users/:id', async ({ params }) => {
await delay(50);
return HttpResponse.json({ id: Number(params.id), name: 'Leeting Yan' });
}),
http.post('/api/orders', async ({ request }) => {
const body = (await request.json()) as { sku: string; qty: number };
if (body.qty <= 0) {
return HttpResponse.json({ message: '数量必须大于 0' }, { status: 422 });
}
return HttpResponse.json({ id: 'ord_123', ...body }, { status: 201 });
}),
];
// test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from '../mocks/handlers';
import { afterAll, afterEach, beforeAll } from 'vitest';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
5.3 用 MSW 断言请求
import { server } from '../test/setup';
import { http, HttpResponse } from 'msw';
it('提交订单时发送正确的请求体', async () => {
const captured: unknown[] = [];
server.use(
http.post('/api/orders', async ({ request }) => {
captured.push(await request.json());
return HttpResponse.json({ id: 'ord_1' }, { status: 201 });
}),
);
const user = userEvent.setup();
render(<OrderForm />);
await user.type(screen.getByLabelText('商品编码'), 'SKU-9');
await user.click(screen.getByRole('button', { name: '下单' }));
expect(await screen.findByText('下单成功')).toBeInTheDocument();
expect(captured).toEqual([{ sku: 'SKU-9', qty: 1 }]);
});
一句话:
onUnhandledRequest: 'error'是 MSW 最重要的一行配置——它让"忘记 mock 的请求"变成显式失败,而不是静默穿透到真实网络造成假通过。
六、快照测试与可访问性断言
6.1 快照测试的取舍
| 快照类型 | 推荐度 | 说明 |
|---|---|---|
| 大组件整树快照 | 不推荐 | 几百行 diff,review 时直接 -u |
| 小型纯展示组件 | 可接受 | 结构稳定,diff 可读 |
内联快照 toMatchInlineSnapshot | 推荐 | 快照就在测试文件里,review 必然看到 |
| 序列化后的可访问性树快照 | 推荐 | 只含语义结构,噪声极低 |
// ✓ 推荐:内联快照,快照就在测试文件里,review 必然看到
import { render } from '@testing-library/react';
it('表格组件的语义结构稳定', () => {
const { container } = render(<DataTable rows={rows} />);
expect(container).toMatchInlineSnapshot(`
<table>
<caption>订单列表</caption>
<thead><tr><th scope="col">编号</th><th scope="col">金额</th></tr></thead>
<tbody><tr><td>1001</td><td>¥500.00</td></tr></tbody>
</table>
`);
});
6.2 jest-axe 可访问性断言
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('登录表单无 WCAG 违规', async () => {
const { container } = render(<LoginForm />);
expect(await axe(container)).toHaveNoViolations();
});
it('错误态下依然无违规(含 aria-describedby 关联)', async () => {
const { container } = render(<LoginForm error="密码错误" />);
// 关掉与本次无关的规则,聚焦表单语义
const results = await axe(container, { rules: { region: { enabled: false } } });
expect(results).toHaveNoViolations();
});
一句话:把
expect(container).toHaveNoViolations()作为组件测试的固定收尾,可访问性就从一个"上线前专项"变成"每次提交的默认保障"。
七、Vue 与 React 的差异
7.1 工具链对照
| 维度 | React | Vue 3 |
|---|---|---|
| 渲染工具 | @testing-library/react | @testing-library/vue / Vue Test Utils |
| 查询 API | screen.getByRole | 同左(共享 DOM Testing Library) |
| 用户事件 | @testing-library/user-event | 同左 |
| 状态更新 | 自动 act() 包裹 | 需 await nextTick() |
| Props 断言 | rerender 传新 props | setProps |
| 全局插件 | Provider 包裹 | global.plugins 配置 |
| 组合式逻辑测试 | renderHook | 直接调用 composable + effectScope |
7.2 Vue 组件测试示例
// Counter.spec.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { nextTick } from 'vue';
import Counter from './Counter.vue';
it('点击后计数递增并更新 aria-live 区域', async () => {
const user = userEvent.setup();
render(Counter, { props: { initial: 0 } });
await user.click(screen.getByRole('button', { name: '增加' }));
await nextTick(); // Vue 需显式等待响应式更新
expect(screen.getByRole('status')).toHaveTextContent('当前计数:1');
});
// useCart.spec.ts — 组合式函数单独测试,无需渲染
import { effectScope } from 'vue';
import { useCart } from './useCart';
it('添加同款商品时合并数量', () => {
const scope = effectScope();
const cart = scope.run(() => useCart())!;
cart.add({ sku: 'A', qty: 1 });
cart.add({ sku: 'A', qty: 2 });
expect(cart.items.value).toEqual([{ sku: 'A', qty: 3 }]);
expect(cart.total.value).toBe(3);
scope.stop(); // 清理副作用,避免跨测试污染
});
一句话:Vue 与 React 的组件测试 90% 的写法一致(共享 DOM Testing Library),差异集中在响应式更新的等待方式和全局插件注入两点上。
八、常见陷阱
| 陷阱 | 现象 | 规避 |
|---|---|---|
| 用 querySelector 查询 | 改类名/结构就碎 | 一律 getByRole / getByLabelText |
| 断言内部 state | 重构即红,价值低 | 只断言用户可见结果 |
| fireEvent 代替 userEvent | 交互序列不真实 | 统一 userEvent.setup() |
| waitFor 里加 timeout 掩盖 flaky | 越跑越慢仍偶发红 | 查根因,优先 findBy |
大快照无脑 -u | 掩盖真实回归 | 内联快照 + 可访问性树快照 |
| MSW 未配 onUnhandledRequest | 请求静默穿透,假通过 | 设为 ’error' |
| 测试间共享 server 状态 | 顺序依赖、随机红 | afterEach resetHandlers |
| 只测 happy path | 错误态零覆盖 | 每个组件必测 loading/error/empty |
九、总结
前端组件测试的核心不是"把组件渲染出来点一点",而是用用户能感知的语义建立断言。Testing Library 的查询优先级把可访问性校验内建进每一次 getByRole;userEvent 用真实交互序列替代粗糙的事件派发;findBy* 让异步等待变得声明式而不需要 waitFor 的逃生舱;Storybook 的 play 函数把交互测试写进 Story,让组件状态成为可枚举的资产;MSW 在网络层拦截,使请求参数成为可断言的契约,配合 onUnhandledRequest: 'error' 杜绝假通过;可访问性树快照与 jest-axe 则把无障碍从专项审计变成默认保障。React 与 Vue 的差异远小于共性——共享 DOM Testing Library,只在响应式等待与插件注入上分道。落地记住五件事:查询靠角色、交互用 userEvent、异步用 findBy、网络用 MSW、每个组件必测 loading/error/empty。当你的组件测试能在不读组件源码的情况下读懂"用户经历了什么"时,它才真正成为重构的安全网。
延伸阅读:https://plumephp.com/accessibility-testing/ 深入 WCAG 与自动化可访问性测试体系,https://plumephp.com/visual-testing-regression/ 了解像素级回归与截图对比,https://plumephp.com/e2e-testing-playwright/ 了解组件测试之上的端到端编排。更多测试工程实践见 /posts/testing/。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。