组件测试(Testing Library)

掌握 Testing Library 的核心理念与 API,学会以用户视角编写组件测试。涵盖查询优先级、用户事件模拟、异步组件测试、Context/Router 集成、MSW 网络 Mock 等完整实践。

Testing Library 的核心哲学

"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds, Testing Library 作者

Testing Library 不是又一个测试框架,而是一套测试理念。它的核心主张是:测试行为,而非实现细节。通过模拟用户与 UI 的真实交互方式来编写测试,获得最大的重构信心。


一、核心 API 体系

1.1 render — 渲染组件

import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('renders user name', () => {
  render(<UserProfile user={{ name: 'Alice', email: 'alice@example.com' }} />);

  expect(screen.getByText('Alice')).toBeInTheDocument();
});

render 返回的对象:

const {
  container,       // 渲染的 DOM 容器
  baseElement,     // document.body
  debug,           // 打印 DOM 树(调试用)
  rerender,        // 用新 props 重新渲染
  unmount,         // 卸载组件
  asFragment,      // 返回 DocumentFragment(快照用)
} = render(<Component />);

1.2 screen — 全局查询对象

screen 是 Testing Library 推荐的查询入口,绑定到 document.body

import { screen } from '@testing-library/react';

// ✅ 推荐 — 使用 screen
screen.getByRole('button', { name: 'Submit' });

// ❌ 不推荐 — 从 render 解构
const { getByRole } = render(<Form />);
getByRole('button', { name: 'Submit' });

1.3 查询类型矩阵

前缀 0 个匹配 1 个匹配 多个匹配 异步 用途
getBy ❌ 抛错 ✅ 返回元素 ❌ 抛错 元素必须存在
queryBy ✅ 返回 null ✅ 返回元素 ❌ 抛错 断言元素不存在
findBy ❌ 抛错 ✅ 返回 Promise ❌ 抛错 等待元素出现
getAllBy ❌ 抛错 ✅ 返回数组 ✅ 返回数组 多个元素
queryAllBy ✅ 返回 [] ✅ 返回数组 ✅ 返回数组 可能没有元素
findAllBy ❌ 抛错 ✅ 返回 Promise ✅ 返回 Promise 等待多个元素

二、查询优先级 — 从用户视角出发

2.1 官方推荐的查询优先级

Testing Library 的查询优先级体现了其核心理念——像用户一样查找元素

1. getByRole         ← 最推荐:基于 ARIA Role(按钮、链接、表单控件)
2. getByLabelText    ← 表单元素:通过 label 关联
3. getByPlaceholderText ← placeholder 文本
4. getByText         ← 通过可见文本内容
5. getByDisplayValue ← 表单当前值
6. getByAltText      ← 图片 alt 文本
7. getByTitle        ← title 属性
8. getByTestId       ← 最后手段:data-testid 属性

2.2 getByRole — 最强大的查询

// 按钮
screen.getByRole('button', { name: 'Submit' });
screen.getByRole('button', { name: /submit/i }); // 正则不区分大小写

// 链接
screen.getByRole('link', { name: 'Home' });

// 表单控件
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('checkbox', { name: 'Agree to terms' });
screen.getByRole('combobox', { name: 'Country' });

// 带状态的查询
screen.getByRole('button', { name: 'Submit', pressed: true });
screen.getByRole('checkbox', { checked: true });
screen.getByRole('tab', { selected: true });

// 标题层级
screen.getByRole('heading', { level: 2 }); // <h2>

为什么 getByRole 最推荐

  1. 它验证了组件的可访问性(Accessibility) — 如果 getByRole 找不到元素,通常意味着你的 HTML 语义有问题
  2. 不依赖具体文本(支持正则),对国际化友好
  3. 不依赖 DOM 结构,重构后测试依然有效

2.3 何时使用 data-testid

当元素没有可访问的名称、文本或角色时(如装饰性 div、复杂的自定义组件):

// 组件
<div data-testid="loading-spinner" className={styles.spinner} />

// 测试
screen.getByTestId('loading-spinner');

三、用户事件模拟

3.1 userEvent vs fireEvent

import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/react';

// ✅ 推荐 — userEvent:模拟完整的用户交互序列
const user = userEvent.setup();
await user.click(button);    // 触发 pointerdown → mousedown → pointerup → mouseup → click
await user.type(input, 'hello'); // 逐字符触发 keydown → keypress → input → keyup

// ❌ 不推荐 — fireEvent:只触发单个事件
fireEvent.click(button);      // 只触发 click 事件
fireEvent.change(input, { target: { value: 'hello' } }); // 直接设置值

3.2 常用 userEvent API

const user = userEvent.setup();

// 点击
await user.click(element);
await user.dblClick(element);
await user.tripleClick(element);  // 选中整行文本

// 键盘输入
await user.type(input, 'Hello World');
await user.clear(input);                    // 清空输入框
await user.type(input, '{Enter}');           // 按回车
await user.type(input, '{Shift>}AB{/Shift}'); // Shift+A, Shift+B

// 键盘快捷键
await user.keyboard('{Control>}a{/Control}'); // Ctrl+A 全选
await user.keyboard('{Meta>}c{/Meta}');       // Cmd+C 复制

// 选择
await user.selectOptions(select, ['option1', 'option2']);
await user.deselectOptions(select, ['option1']);

// Tab 导航
await user.tab();
expect(nextInput).toHaveFocus();

// 悬停
await user.hover(element);
await user.unhover(element);

// 拖拽 / 上传
await user.upload(fileInput, file);
await user.pointer([
  { target: element, keys: '[MouseLeft>]' },
  { coords: { x: 100, y: 200 } },
  { keys: '[/MouseLeft]' },
]);

3.3 表单交互完整示例

test('should submit login form', async () => {
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  const user = userEvent.setup();

  // 填写表单
  await user.type(screen.getByRole('textbox', { name: /email/i }), '[email protected]');
  await user.type(screen.getByLabelText(/password/i), 'secret123');

  // 勾选记住我
  await user.click(screen.getByRole('checkbox', { name: /remember me/i }));

  // 提交
  await user.click(screen.getByRole('button', { name: /sign in/i }));

  expect(onSubmit).toHaveBeenCalledWith({
    email: '[email protected]',
    password: 'secret123',
    rememberMe: true,
  });
});

四、异步组件测试

4.1 findBy — 等待元素出现

test('should display user data after loading', async () => {
  render(<UserProfile userId={1} />);

  // 初始状态:显示 loading
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // 等待数据加载完成
  const userName = await screen.findByText('Alice');
  expect(userName).toBeInTheDocument();

  // loading 消失
  expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});

4.2 waitFor — 等待断言通过

import { waitFor } from '@testing-library/react';

test('should update count after API call', async () => {
  render(<Counter />);

  const user = userEvent.setup();
  await user.click(screen.getByRole('button', { name: 'Increment' }));

  await waitFor(() => {
    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

4.3 waitForElementToBeRemoved

test('should remove loading indicator', async () => {
  render(<DataTable />);

  // 等待 loading 消失
  await waitForElementToBeRemoved(() => screen.queryByText('Loading...'));

  // 然后断言数据已渲染
  expect(screen.getByRole('table')).toBeInTheDocument();
});

五、Provider 与 Context 集成

5.1 自定义 render 函数

大多数 React 应用需要 Provider 包裹(Router、Theme、Store 等)。创建自定义 render:

// src/test/test-utils.tsx
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ThemeProvider } from './theme';

interface CustomRenderOptions extends RenderOptions {
  initialRoute?: string;
  queryClient?: QueryClient;
}

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0 },
      mutations: { retry: false },
    },
  });
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    initialRoute = '/',
    queryClient = createTestQueryClient(),
    ...renderOptions
  }: CustomRenderOptions = {}
) {
  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <MemoryRouter initialEntries={[initialRoute]}>
          <ThemeProvider>{children}</ThemeProvider>
        </MemoryRouter>
      </QueryClientProvider>
    );
  }

  return {
    ...render(ui, { wrapper: Wrapper, ...renderOptions }),
    queryClient,
  };
}

// 重新导出 Testing Library 的所有方法
export * from '@testing-library/react';
export { renderWithProviders as render };

5.2 使用自定义 render

// 测试文件中
import { render, screen } from '@/test/test-utils';

test('should navigate to profile page', async () => {
  render(<App />, { initialRoute: '/profile' });

  expect(screen.getByRole('heading', { name: /profile/i })).toBeInTheDocument();
});

六、MSW 集成 — 网络层 Mock

6.1 Setup

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

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: Number(params.id),
      name: 'Alice',
      email: '[email protected]',
    });
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json();
    if (body.email === '[email protected]') {
      return HttpResponse.json({ token: 'fake-token' });
    }
    return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }),
];

// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

6.2 在测试中使用

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

// setup.ts
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('should display user profile', async () => {
  render(<UserProfile userId={1} />);

  expect(await screen.findByText('Alice')).toBeInTheDocument();
  expect(screen.getByText('[email protected]')).toBeInTheDocument();
});

test('should handle error state', async () => {
  // 覆盖特定 handler 模拟错误
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json({ error: 'Not found' }, { status: 404 });
    })
  );

  render(<UserProfile userId={999} />);

  expect(await screen.findByText('User not found')).toBeInTheDocument();
});

七、Testing Library 反模式

7.1 不要测试实现细节

// ❌ 测试实现细节
test('bad: testing state directly', () => {
  const { result } = renderHook(() => useState(0));
  act(() => result.current[1](1));
  expect(result.current[0]).toBe(1); // 测试了 state 的内部值
});

// ✅ 测试用户可见的行为
test('good: testing user-visible behavior', async () => {
  render(<Counter />);
  const user = userEvent.setup();

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

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});

7.2 不要用 container.querySelector

// ❌ 依赖 DOM 结构
const { container } = render(<Form />);
const input = container.querySelector('input.email-field');

// ✅ 语义化查询
screen.getByRole('textbox', { name: /email/i });

7.3 不要 waitFor 包裹 userEvent

// ❌ 不必要的 waitFor
await waitFor(() => {
  userEvent.click(button); // userEvent 本身就是异步的
});

// ✅ 直接 await
await user.click(button);

八、Hook 测试

8.1 renderHook

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

test('should increment counter', () => {
  const { result } = renderHook(() => useCounter(0));

  expect(result.current.count).toBe(0);

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

8.2 带 Provider 的 Hook 测试

test('should use theme context', () => {
  const wrapper = ({ children }: { children: React.ReactNode }) => (
    <ThemeProvider theme="dark">{children}</ThemeProvider>
  );

  const { result } = renderHook(() => useTheme(), { wrapper });

  expect(result.current.theme).toBe('dark');
});

九、面试高频题

题型 1:为什么 Testing Library 不推荐 Enzyme 的 shallow rendering?

Shallow rendering 只渲染当前组件,不渲染子组件。这看似"隔离"了测试,实际上:

  1. 不反映真实行为:用户永远看到完整渲染结果
  2. 依赖实现细节:你需要知道子组件的存在
  3. 重构困难:拆分/合并组件时测试会大量失败
  4. Testing Library 的 render 始终完整渲染,搭配 MSW Mock 网络层即可

题型 2:getByRole 找不到元素怎么排查?

  1. screen.debug() — 打印当前 DOM
  2. screen.logTestingPlaygroundURL() — 生成 Testing Playground 链接
  3. 检查元素的 隐式 ARIA Role(如 <button> 自带 role="button"
  4. 检查是否被 aria-hidden 隐藏
  5. 使用 { hidden: true } 选项查找隐藏元素

题型 3:如何测试一个需要 debounce 的搜索框?

test('should search after debounce', async () => {
  vi.useFakeTimers();
  const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });

  render(<SearchBox />);

  await user.type(screen.getByRole('searchbox'), 'react');

  // 快进 debounce 时间
  act(() => vi.advanceTimersByTime(300));

  expect(await screen.findByText('Results for: react')).toBeInTheDocument();

  vi.useRealTimers();
});

十、与其他主题的关联

关联主题 关系
unit-testing 组件测试基于 Vitest 运行
testing-philosophy 组件测试是 Testing Trophy 的核心层
mocking-strategies MSW 是组件测试中 Mock 网络层的最佳方案
e2e-testing 组件测试覆盖不了的跨页面流程由 E2E 补充
a11y-strategy getByRole 查询同时验证了组件的可访问性

参考资料

延展阅读