Skip to content

深入理解 React 测试体系

Vitest + Testing Library + MSW 基础使用、组件测试、Hook 测试、集成测试与最佳实践

什么是 React 测试?

定义:React 测试是通过自动化工具验证组件行为是否符合预期的过程。2026 年主流的测试栈是 Vitest(测试运行器)+ Testing Library(组件测试)+ MSW(网络请求 Mock)。核心理念是按用户行为测试,而非测试实现细节。

涉及场景

  • 组件测试:验证渲染输出、用户交互、条件渲染
  • Hook 测试:验证自定义 Hook 的状态变化和副作用
  • 集成测试:验证多个组件协作、数据流、路由跳转
  • API Mock:拦截网络请求返回固定数据,避免依赖真实后端
  • 快照测试:检测 UI 意外变更
  • 可访问性测试:验证组件满足 ARIA 标准

作用

  1. 防止回归:修改代码后自动验证原有功能是否正常
  2. 提升信心:重构时有测试保障,敢于大胆改动
  3. 文档效果:测试用例描述了组件的预期行为
  4. 面试考点:测试策略、Testing Library 用法、MSW Mock 方式是常见考题

一、测试栈搭建

安装

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom \
  @testing-library/user-event jsdom msw

Vitest 配置

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
    css: true,
  },
});
typescript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

// 每个测试后自动清理 DOM
afterEach(() => {
  cleanup();
});

二、组件测试基础

渲染与查询

tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';

function Greeting({ name }: { name: string }) {
  return <h1>你好,{name}!</h1>;
}

describe('Greeting', () => {
  it('显示问候语', () => {
    render(<Greeting name="张三" />);
    expect(screen.getByText('你好,张三!')).toBeInTheDocument();
  });

  it('渲染 h1 标签', () => {
    render(<Greeting name="李四" />);
    expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('李四');
  });
});

查询优先级

tsx
// Testing Library 推荐的查询优先级(从高到低):

// 1. getByRole — 最推荐,模拟辅助技术的访问方式
screen.getByRole('button', { name: '提交' });
screen.getByRole('textbox', { name: '用户名' });
screen.getByRole('heading', { level: 2 });

// 2. getByLabelText — 表单元素
screen.getByLabelText('邮箱');

// 3. getByPlaceholderText
screen.getByPlaceholderText('请输入搜索关键词');

// 4. getByText — 非交互元素
screen.getByText('欢迎使用');
screen.getByText(/欢迎/i); // 支持正则

// 5. getByDisplayValue — 表单当前值
screen.getByDisplayValue('当前输入内容');

// 6. getByAltText — 图片
screen.getByAltText('用户头像');

// 7. getByTitle
screen.getByTitle('关闭');

// 8. getByTestId — 最后手段
screen.getByTestId('custom-element');

查询变体

tsx
// getBy — 同步查询,找不到抛错
screen.getByText('存在的文本');

// queryBy — 同步查询,找不到返回 null(用于断言不存在)
expect(screen.queryByText('不存在的文本')).not.toBeInTheDocument();

// findBy — 异步查询,等待元素出现(返回 Promise)
const element = await screen.findByText('异步加载的内容');

// getAllBy / queryAllBy / findAllBy — 返回数组
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);

三、用户交互测试

tsx
import userEvent from '@testing-library/user-event';

describe('LoginForm', () => {
  it('填写表单并提交', async () => {
    const handleSubmit = vi.fn();
    const user = userEvent.setup(); // 每个测试创建新实例

    render(<LoginForm onSubmit={handleSubmit} />);

    // 输入
    await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
    await user.type(screen.getByLabelText('密码'), 'password123');

    // 点击
    await user.click(screen.getByRole('button', { name: '登录' }));

    // 断言
    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });

  it('空表单提交显示验证错误', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.click(screen.getByRole('button', { name: '登录' }));

    expect(screen.getByText('请输入邮箱')).toBeInTheDocument();
    expect(screen.getByText('请输入密码')).toBeInTheDocument();
  });

  it('密码可见性切换', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    const passwordInput = screen.getByLabelText('密码');
    expect(passwordInput).toHaveAttribute('type', 'password');

    await user.click(screen.getByRole('button', { name: '显示密码' }));
    expect(passwordInput).toHaveAttribute('type', 'text');
  });
});

常用交互方法

tsx
const user = userEvent.setup();

// 点击
await user.click(element);
await user.dblClick(element);

// 输入
await user.type(input, 'hello');
await user.clear(input);

// 键盘
await user.keyboard('{Enter}');
await user.keyboard('{Control>}a{/Control}'); // Ctrl+A

// 选择
await user.selectOptions(select, 'option-value');

// Tab 导航
await user.tab();

// Hover
await user.hover(element);
await user.unhover(element);

// 上传文件
const file = new File(['content'], 'test.png', { type: 'image/png' });
await user.upload(fileInput, file);

四、异步测试

tsx
import { render, screen, waitFor } from '@testing-library/react';

describe('UserProfile', () => {
  it('加载并显示用户信息', async () => {
    render(<UserProfile userId="1" />);

    // 等待加载完成
    expect(screen.getByText('加载中...')).toBeInTheDocument();

    // findBy 自动等待元素出现(默认超时 1000ms)
    const userName = await screen.findByText('张三');
    expect(userName).toBeInTheDocument();

    // 加载指示器消失
    expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
  });

  it('显示错误信息', async () => {
    // Mock 失败请求(配合 MSW)
    server.use(
      http.get('/api/users/1', () => {
        return HttpResponse.json({ message: '用户不存在' }, { status: 404 });
      })
    );

    render(<UserProfile userId="1" />);

    await waitFor(() => {
      expect(screen.getByText('用户不存在')).toBeInTheDocument();
    });
  });
});

五、自定义 Hook 测试

tsx
import { renderHook, act } from '@testing-library/react';

// 被测 Hook
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initial);
  return { count, increment, decrement, reset };
}

describe('useCounter', () => {
  it('初始值', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('递增', () => {
    const { result } = renderHook(() => useCounter());
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it('重置', () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => {
      result.current.increment();
      result.current.increment();
    });
    expect(result.current.count).toBe(7);
    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });

  it('参数变化时重新初始化', () => {
    const { result, rerender } = renderHook(
      ({ initial }) => useCounter(initial),
      { initialProps: { initial: 0 } }
    );

    expect(result.current.count).toBe(0);
    rerender({ initial: 10 }); // 重新渲染,传入新参数
    // 注意:useCounter 不会自动 reset,这取决于 Hook 实现
  });
});

// 测试带有异步操作的 Hook
describe('useFetch', () => {
  it('获取数据', async () => {
    const { result } = renderHook(() => useFetch('/api/users'));

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual([{ id: 1, name: '张三' }]);
    expect(result.current.error).toBeNull();
  });
});

六、MSW(Mock Service Worker)

配置

typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  // GET 请求
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '张三', email: 'zhang@example.com' },
      { id: 2, name: '李四', email: 'li@example.com' },
    ]);
  }),

  // 带参数的 GET
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params;
    if (id === '999') {
      return HttpResponse.json({ message: '用户不存在' }, { status: 404 });
    }
    return HttpResponse.json({ id, name: '张三' });
  }),

  // POST 请求
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 3, ...body },
      { status: 201 }
    );
  }),

  // DELETE
  http.delete('/api/users/:id', () => {
    return new HttpResponse(null, { status: 204 });
  }),
];
typescript
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
typescript
// src/test/setup.ts(追加)
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

在测试中覆盖 Handler

tsx
import { server } from '@/test/mocks/server';
import { http, HttpResponse } from 'msw';

describe('UserList', () => {
  it('正常显示用户列表', async () => {
    // 使用默认 handler
    render(<UserList />);
    expect(await screen.findByText('张三')).toBeInTheDocument();
    expect(screen.getByText('李四')).toBeInTheDocument();
  });

  it('服务器错误时显示错误提示', async () => {
    // 覆盖 handler(只对这个测试生效)
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json(
          { message: '服务器内部错误' },
          { status: 500 }
        );
      })
    );

    render(<UserList />);
    expect(await screen.findByText('加载失败')).toBeInTheDocument();
  });

  it('空列表', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json([]);
      })
    );

    render(<UserList />);
    expect(await screen.findByText('暂无数据')).toBeInTheDocument();
  });
});

七、集成测试

测试带路由的组件

tsx
import { createMemoryRouter, RouterProvider } from 'react-router';

function renderWithRouter(routes, initialPath = '/') {
  const router = createMemoryRouter(routes, {
    initialEntries: [initialPath],
  });
  return render(<RouterProvider router={router} />);
}

describe('App 路由', () => {
  it('首页', () => {
    renderWithRouter(appRoutes, '/');
    expect(screen.getByText('欢迎')).toBeInTheDocument();
  });

  it('导航到文章页', async () => {
    const user = userEvent.setup();
    renderWithRouter(appRoutes, '/');

    await user.click(screen.getByRole('link', { name: '文章' }));

    expect(await screen.findByText('文章列表')).toBeInTheDocument();
  });

  it('404 页面', () => {
    renderWithRouter(appRoutes, '/nonexistent');
    expect(screen.getByText('页面不存在')).toBeInTheDocument();
  });
});

测试带 Provider 的组件

tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // 测试中不重试
        gcTime: Infinity,
      },
    },
  });
}

function renderWithProviders(ui: React.ReactElement) {
  const queryClient = createTestQueryClient();
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

describe('Dashboard', () => {
  it('加载数据并展示', async () => {
    renderWithProviders(<Dashboard />);

    expect(screen.getByText('加载中...')).toBeInTheDocument();
    expect(await screen.findByText('总用户数: 100')).toBeInTheDocument();
  });
});

八、测试最佳实践

测什么、不测什么

✅ 应该测:
  - 用户可见的行为(渲染内容、交互响应)
  - 条件渲染(不同 props/state 下的输出)
  - 错误处理(错误边界、API 错误)
  - 表单验证和提交
  - 可访问性(正确的 ARIA 角色和标签)

❌ 不应该测:
  - 实现细节(组件内部状态值、私有方法)
  - 第三方库本身(React Router、TanStack Query)
  - CSS 样式(除非是关键的条件样式)
  - 常量和配置

AAA 模式

tsx
it('添加待办事项', async () => {
  // Arrange(准备)
  const user = userEvent.setup();
  render(<TodoApp />);

  // Act(操作)
  await user.type(screen.getByRole('textbox'), '学习 React');
  await user.click(screen.getByRole('button', { name: '添加' }));

  // Assert(断言)
  expect(screen.getByText('学习 React')).toBeInTheDocument();
  expect(screen.getByRole('textbox')).toHaveValue(''); // 输入框已清空
});

测试覆盖率

bash
# vitest 内置覆盖率支持
npx vitest run --coverage

# vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: ['node_modules/', 'src/test/'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
});

面试高频题

Q: 为什么推荐 getByRole 而不是 getByTestId?

getByRole 模拟了辅助技术(屏幕阅读器)的访问方式,按用户体验来查询元素。如果 getByRole 找不到元素,说明你的组件可能存在可访问性问题。getByTestId 是与实现耦合的方案,只在其他查询都不适用时才使用。

Q: userEvent 和 fireEvent 的区别?

  • fireEvent:直接触发 DOM 事件,跳过浏览器的事件流程
  • userEvent:模拟真实用户行为,包括焦点变化、键盘事件序列、输入法处理等。例如 user.type(input, 'abc') 会触发 focus → keydown → keypress → input → keyup(对每个字符)
  • 推荐始终使用 userEvent

Q: 如何测试带有 API 请求的组件?

使用 MSW(Mock Service Worker)拦截网络请求。MSW 在 Service Worker 层面拦截,不需要修改组件代码。在 setupFiles 中启动 Mock 服务器,在 handlers 中定义默认响应,在单个测试中可以用 server.use() 覆盖。

Q: act() 是什么?什么时候需要手动使用?

act() 确保 React 的状态更新和副作用在断言前完成。renderuserEventwaitFor 已经内部使用了 act。手动使用的场景:直接调用 renderHook 返回的方法更新状态时,需要用 act 包裹。