讲解
React 测试的标准装备:Vitest(测试运行器,与 Vite 项目共用配置)+ React Testing Library(RTL,渲染组件、查找元素、触发事件)+ jest-dom(toBeInTheDocument 等断言增强)。RTL 的设计哲学是「像用户一样测试」:不查 class 名、不测内部 state,而是按角色/文本/标签找元素(getByRole('button', { name: '保存' })),断言用户能看到的行为。内部重构(改 class、调 state 结构)不该让测试翻车。
一个组件测试的三段式:渲染(render())、交互(await userEvent.click(...) 或 fireEvent)、断言(expect(screen.getByText(...)).toBeInTheDocument())。userEvent 比 fireEvent 更接近真实交互(会触发完整事件序列),新代码优先用它。查询方法的选择有讲究:getBy 找不到立刻报错(适合「应该在」),queryBy 找不到返回 null(适合「不该在」的断言),findBy 是异步版(等元素出现)。
测 Hook 用 renderHook;异步行为用 waitFor 轮询断言;网络请求用 vi.fn() mock fetch 或 MSW 拦截——测试必须离线可跑、毫秒级完成。最后:覆盖率别追求 100%,关键路径(表单提交、支付流程、权限判断)覆盖到位比数字好看重要。
示例
先测纯逻辑——reducer 和工具函数不需要任何测试库,Node 断言就够(这也是本教程的自我验证方式,以下代码真实执行):
import assert from 'node:assert/strict';
// 被测对象 1:表单校验(纯函数)
function validate(form) {
const errors = {};
if (!form.name || form.name.trim().length < 2) errors.name = '姓名至少 2 个字';
if (!form.agreed) errors.agreed = '请勾选协议';
return errors;
}
// 表驱动测试:一组用例循环断言
const cases = [
[{ name: '', agreed: false }, ['name', 'agreed']],
[{ name: '小明', agreed: false }, ['agreed']],
[{ name: ' ', agreed: true }, ['name']],
[{ name: '小明', agreed: true }, []],
];
for (const [input, expectedKeys] of cases) {
assert.deepStrictEqual(Object.keys(validate(input)), expectedKeys);
}
assert.strictEqual(cases.length, 4);
// 被测对象 2:购物车 reducer(纯函数)
function cartReducer(state, action) {
if (action.type === 'add') {
const found = state.find((i) => i.id === action.item.id);
if (found) return state.map((i) => (i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i));
return [...state, { ...action.item, qty: 1 }];
}
return state;
}
let cart = [];
cart = cartReducer(cart, { type: 'add', item: { id: 1, name: '键盘' } });
cart = cartReducer(cart, { type: 'add', item: { id: 1, name: '键盘' } });
cart = cartReducer(cart, { type: 'add', item: { id: 2, name: '鼠标' } });
assert.strictEqual(cart.length, 2);
assert.strictEqual(cart[0].qty, 2); // 同款合并数量
console.log('validate: 4 组表驱动用例全部通过');
console.log('cartReducer: 加购合并 qty =', cart[0].qty, ',共', cart.length, '种商品');
真实项目中的组件测试(Vitest + RTL):
// 仅示意:需要安装 vitest、@testing-library/react 等依赖,构建时不执行
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import Counter from './Counter';
describe('Counter', () => {
it('显示初始值', () => {
render(<Counter start={5} />);
expect(screen.getByText('5')).toBeInTheDocument();
});
it('点击后加一', async () => {
render(<Counter start={0} />);
await userEvent.click(screen.getByRole('button', { name: '加一' }));
expect(screen.getByText('1')).toBeInTheDocument();
});
it('到达 max 后按钮禁用', async () => {
render(<Counter start={1} max={2} />);
await userEvent.click(screen.getByRole('button', { name: '加一' }));
expect(screen.getByRole('button')).toBeDisabled();
});
});
常见坑
- 测实现细节:断言内部 state、class 名、方法调用——重构一次全红;按用户可见行为断言。
- 异步更新忘记 await:事件后的状态更新是异步的,userEvent 和 findBy/waitFor 都要 await。
- getBy 当 queryBy 用:断言「不该存在」要用 queryBy(返回 null),getBy 找不到会直接抛错。
- 真实请求进测试:慢、不稳、依赖网络;fetch 用 vi.fn() mock 或 MSW 拦截。
- 追求覆盖率数字:80% 的关键路径覆盖胜过 100% 的边角覆盖;先写表单、支付、权限的用例。
小结
Vitest + RTL,像用户一样测试:getByRole 找元素、userEvent 交互、断言可见行为;纯逻辑用表驱动直接断言。下一章看 React 的并发特性。