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 最推荐?
- 它验证了组件的可访问性(Accessibility) — 如果
getByRole找不到元素,通常意味着你的 HTML 语义有问题 - 它不依赖具体文本(支持正则),对国际化友好
- 它不依赖 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 只渲染当前组件,不渲染子组件。这看似"隔离"了测试,实际上:
- 不反映真实行为:用户永远看到完整渲染结果
- 依赖实现细节:你需要知道子组件的存在
- 重构困难:拆分/合并组件时测试会大量失败
- Testing Library 的 render 始终完整渲染,搭配 MSW Mock 网络层即可
题型 2:getByRole 找不到元素怎么排查?
screen.debug()— 打印当前 DOMscreen.logTestingPlaygroundURL()— 生成 Testing Playground 链接- 检查元素的 隐式 ARIA Role(如
<button>自带role="button") - 检查是否被
aria-hidden隐藏 - 使用
{ 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 查询同时验证了组件的可访问性 |
参考资料
- Testing Library 官方文档 — testing-library.com
- Kent C. Dodds, Common Mistakes with React Testing Library
- Kent C. Dodds, Testing Implementation Details
- Testing Library Query Priority — testing-library.com/docs/queries/about#priority
- MSW 官方文档 — mswjs.io
- Testing Playground — testing-playground.com — 交互式查询调试工具