一句话定义
React 测试按「金字塔」分层:纯逻辑用单元测试(Vitest)、组件按用户可见行为测试(Testing Library)、关键路径做端到端(Playwright)——工具选型的原则是「测用户看到的,不测实现细节」。
为什么重要
React 组件最大的测试陷阱是「测实现」:mock 到 useState、断言 className 组合的测试在重构时全线崩红,却不拦任何真 bug。这一篇给出可持续的分层策略——它决定你的测试资产三个月后是护城河还是负债。
前置知识
- kp-015 自定义 Hook 可脱离组件测试。
- 基本的测试概念(describe/it/expect、mock)。
核心概念
- 测试金字塔:大量单元(reducer/工具/Hook)→ 中量组件测试 → 少量 E2E。
- 用户视角查询:
getByRole('button', { name: '提交' })优于getByTestId,因为角色查询同时验证了可访问性。 - 不测实现细节:不断言「state 更新了几次」,只断言「用户看到什么、点击后看到什么」。
- Mock 边界:网络层用 MSW(拦截 fetch,提供真实响应),组件内部逻辑不 mock。
- 钩子测试:renderHook + act,或把 Hook 逻辑下沉为纯函数优先测试。
- 性能回归:Profiler 断言 / 长列表渲染时间预算进 CI(进阶项)。
原理 / 机制
分层示例:一个 todo 应用
jsx// 1. 单元层:reducer 是纯函数,测试零成本(kp-016 的复利)
test('add 去重合并数量', () => {
const state = cartReducer({ items: [{ id: 1, qty: 1 }] },
{ type: 'add', item: { id: 1 } });
expect(state.items).toEqual([{ id: 1, qty: 2 }]);
});
// 2. 组件层:用户行为视角(Testing Library)
test('勾选复选框后提交按钮可用', async () => {
render(<Signup />);
const user = userEvent.setup();
await user.click(screen.getByRole('checkbox'));
expect(screen.getByRole('button', { name: /提交/ })).toBeEnabled();
});
// 3. E2E 层:Playwright 走真实浏览器
test('登录后添加书签', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('邮箱').fill('a@b.c');
await page.getByLabel('密码').fill('secret');
await page.getByRole('button', { name: '登录' }).click();
await page.getByPlaceholder('添加书签').fill('https://react.dev');
await page.keyboard.press('Enter');
await expect(page.getByRole('link', { name: 'react.dev' })).toBeVisible();
});
MSW 处理数据请求(避免 mock 整个 fetch 的脆断言):
jsximport { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/search', ({ request }) => {
const q = new URL(request.url).searchParams.get('q');
return HttpResponse.json([{ id: 1, name: `结果-${q}` }]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers()); // 单测间互不污染
afterAll(() => server.close());
自定义 Hook 测试(kp-015 的可测性回报):
jsxtest('useDebouncedValue 延迟生效', async () => {
const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v, 100),
{ initialProps: { v: 'a' } });
rerender({ v: 'ab' });
expect(result.current).toBe('a'); // 未到延迟
await act(() => advanceBy(150));
expect(result.current).toBe('ab');
});
直观类比
测试分层像质检体系:单元测试是材料检验(每颗螺丝合格);组件测试是部件抽检(车门关得上、按得响);E2E 是整车路试(上高速跑一圈)。只做路试,坏了不知道哪颗螺丝(定位慢、修复贵);只测螺丝,整车可能装反(覆盖假象)。用户视角查询是「让盲人质检员来摸」:他只能靠角色和名字找到东西——所以你的 aria 无障碍顺带被检验了。
实例 / 案例
一个中型项目的分层配比参考:
textVitest 单元: reducer/工具/纯函数 Hook ~200 个,<2s
Testing Lib 组件: 关键交互组件 ~60 个
MSW 拦截: 所有网络边界
Playwright E2E: 登录/下单/支付 3 条主路径 ~8 条
CI PR 必过单测+组件; main 加跑 E2E; Lighthouse 预算
配置三角(Vite 生态):
js// vitest.config.js
import react from '@vitejs/plugin-react';
export default {
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './test/setup.js', // 引入 jest-dom 断言 + MSW
},
};
常见误区
- 断言实现细节:
expect(component.state.count).toBe(3)/ 断言 className 拼接——重构即红,维护即弃。 - 快照测试滥用:大快照「看都不看就 update」;快照只适合小型稳定结构(如渲染的图标 SVG)。
- mock 到失控:mock 掉被测系统本身(mock useState、mock 组件内部);mock 应止步于网络/时钟/存储等「边界」。
- E2E 当第一道防线:慢、脆、贵;只留给「业务断了会流血」的路径。
- 异步断言忘 await:
expect(getByText(...))在用户点击后立即断言会偶发失败;统一用findBy/waitFor(act 警告的同源问题)。 - RSC 场景照搬组件测试:Server Component 的取数逻辑应作为普通 async 函数测试,浏览器环境测不了它(与 kp-026 呼应)。
自测题
- 「测用户看到的,不测实现」——各写一个正确与错误断言的例子。
- 为什么 getByRole 优于 getByTestId?它的副作用收益是什么?
- MSW 相比
vi.mock('./api')的优势是什么?beforeEach 里 resetHandlers 防什么? - 给你的 todo 项目设计测试金字塔:列出每层测什么、各几条。
- 「测试在重构时全线崩红」说明测试的什么问题?如何预防(两条)?
与其他知识点的关系
- 向前:kp-015/016 的抽象让纯逻辑可测;kp-026 的架构决定分层方式。
- 向后:kp-028 的审查清单包含「新代码是否带行为测试」;kp-019 的性能预算进 CI 是本篇的进阶扩展。
延伸阅读
- Testing Library 原则(核心必读):<https://testing-library.com/docs/guiding-principles>
- Vitest 文档:<https://vitest.dev/>
- Playwright:<https://playwright.dev/>
- Common mistakes with React Testing Library:<https://kentcdodds.com/blog/common-mistakes-with-react-testing-library>