讲解

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 的并发特性。