jest-react-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseJest React Testing
Jest React 测试
A comprehensive skill for testing React applications using Jest and React Testing Library. This skill covers everything from basic component testing to advanced patterns including mocking, async testing, custom hooks testing, and integration testing strategies.
这是一项使用Jest和React Testing Library测试React应用的全面技能。本技能涵盖了从基础组件测试到高级模式的所有内容,包括模拟、异步测试、自定义Hooks测试和集成测试策略。
When to Use This Skill
何时使用本技能
Use this skill when:
- Testing React components with Jest and React Testing Library
- Setting up Jest configuration for React projects
- Writing unit tests for components, hooks, and utilities
- Testing user interactions and component behavior
- Mocking modules, functions, API calls, and external dependencies
- Testing asynchronous operations (API calls, timers, promises)
- Testing custom React hooks
- Writing integration tests for complex component trees
- Debugging failing tests or improving test coverage
- Following testing best practices and patterns
在以下场景使用本技能:
- 使用Jest和React Testing Library测试React组件
- 为React项目配置Jest
- 为组件、Hooks和工具函数编写单元测试
- 测试用户交互和组件行为
- 模拟模块、函数、API调用和外部依赖
- 测试异步操作(API调用、定时器、Promise)
- 测试自定义React Hooks
- 为复杂组件树编写集成测试
- 调试失败的测试或提高测试覆盖率
- 遵循测试最佳实践和模式
Core Concepts
核心概念
Testing Philosophy
测试理念
React Testing Library follows these guiding principles:
- Test User Behavior, Not Implementation: Write tests that resemble how users interact with your app
- Accessibility First: Use queries that promote accessible components (getByRole, getByLabelText)
- Avoid Testing Implementation Details: Don't test state, props, or internal methods directly
- Maintainable Tests: Tests should break when behavior changes, not when code refactors
- Confidence Over Coverage: Focus on tests that give confidence, not 100% coverage
React Testing Library遵循以下指导原则:
- 测试用户行为,而非实现细节:编写与用户和应用交互方式一致的测试
- 可访问性优先:使用支持无障碍组件的查询方法(getByRole、getByLabelText)
- 避免测试实现细节:不要直接测试状态、Props或内部方法
- 可维护的测试:当行为变化时测试应该失败,而不是代码重构时
- 信心优先于覆盖率:专注于能提供信心的测试,而非追求100%覆盖率
Key Testing Concepts
关键测试概念
- Queries: Methods to find elements (getBy, queryBy, findBy)
- User Events: Simulating user interactions (click, type, select)
- Async Testing: Testing components with asynchronous operations
- Mocking: Replacing dependencies with controlled test doubles
- Assertions: Verifying expected outcomes with matchers
- 查询方法:查找元素的方法(getBy、queryBy、findBy)
- 用户事件:模拟用户交互(点击、输入、选择)
- 异步测试:测试包含异步操作的组件
- 模拟:用可控的测试替身替换依赖项
- 断言:使用匹配器验证预期结果
Jest Configuration
Jest 配置
Basic Jest Configuration
基础Jest配置
jest.config.js (JavaScript projects):
javascript
/** @type {import('jest').Config} */
const config = {
// Test environment for DOM testing
testEnvironment: 'jsdom',
// Setup files after environment
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Module paths
moduleDirectories: ['node_modules', 'src'],
// Transform files with babel-jest
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
},
// Module name mapper for static assets and CSS
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
// Coverage configuration
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/**/*.test.{js,jsx}',
'!src/**/__tests__/**',
],
// Coverage thresholds
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
module.exports = config;jest.config.js (TypeScript projects):
typescript
import type {Config} from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleDirectories: ['node_modules', 'src'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.ts',
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/index.tsx',
'!src/**/*.test.{ts,tsx}',
'!src/**/__tests__/**',
'!src/**/*.d.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;jest.config.js(JavaScript项目):
javascript
/** @type {import('jest').Config} */
const config = {
// DOM测试的测试环境
testEnvironment: 'jsdom',
// 环境初始化后的设置文件
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// 模块路径
moduleDirectories: ['node_modules', 'src'],
// 使用babel-jest转换文件
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
},
// 静态资源和CSS的模块名称映射
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
// 覆盖率配置
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/**/*.test.{js,jsx}',
'!src/**/__tests__/**',
],
// 覆盖率阈值
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
module.exports = config;jest.config.js(TypeScript项目):
typescript
import type {Config} from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleDirectories: ['node_modules', 'src'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.ts',
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/index.tsx',
'!src/**/*.test.{ts,tsx}',
'!src/**/__tests__/**',
'!src/**/*.d.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
export default config;Setup Files
设置文件
src/setupTests.js:
javascript
// Add custom jest matchers from jest-dom
import '@testing-library/jest-dom';
// Extend expect with jest-extended matchers (optional)
import * as matchers from 'jest-extended';
expect.extend(matchers);
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
// Suppress console errors in tests (optional)
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render')
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});
// Reset mocks after each test
afterEach(() => {
jest.clearAllMocks();
});src/setupTests.js:
javascript
// 从jest-dom添加自定义jest匹配器
import '@testing-library/jest-dom';
// (可选)使用jest-extended匹配器扩展expect
import * as matchers from 'jest-extended';
expect.extend(matchers);
// 模拟window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// 模拟IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
// (可选)在测试中抑制控制台错误
const originalError = console.error;
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render')
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});
// 每次测试后重置模拟
afterEach(() => {
jest.clearAllMocks();
});File Mocks
文件模拟
mocks/fileMock.js:
javascript
module.exports = 'test-file-stub';mocks/styleMock.js:
javascript
module.exports = {};mocks/fileMock.js:
javascript
module.exports = 'test-file-stub';mocks/styleMock.js:
javascript
module.exports = {};React Testing Library Queries
React Testing Library 查询方法
Query Types
查询类型
React Testing Library provides three types of queries:
- getBy: Returns element or throws error (use for elements that should exist)
- queryBy: Returns element or null (use for elements that may not exist)
- findBy: Returns promise that resolves to element (use for async elements)
React Testing Library提供三种查询类型:
- getBy:返回元素或抛出错误(用于应该存在的元素)
- queryBy:返回元素或null(用于可能不存在的元素)
- findBy:返回解析为元素的Promise(用于异步元素)
Query Priority
查询优先级
Recommended Query Order (accessibility-focused):
-
getByRole: Most accessible queryjavascript
getByRole('button', { name: /submit/i }) getByRole('heading', { level: 1 }) getByRole('textbox', { name: /username/i }) -
getByLabelText: For form fields with labelsjavascript
getByLabelText(/email address/i) getByLabelText('Password') -
getByPlaceholderText: For inputs with placeholdersjavascript
getByPlaceholderText(/search/i) -
getByText: For non-interactive elements with textjavascript
getByText(/welcome/i) getByText('Error: Invalid credentials') -
getByDisplayValue: For form elements with valuesjavascript
getByDisplayValue('John Doe') -
getByAltText: For images with alt textjavascript
getByAltText(/profile picture/i) -
getByTitle: For elements with title attributejavascript
getByTitle(/close/i) -
getByTestId: Last resort when other queries don't workjavascript
getByTestId('custom-element')
推荐查询顺序(以可访问性为核心):
-
getByRole:最符合无障碍标准的查询javascript
getByRole('button', { name: /submit/i }) getByRole('heading', { level: 1 }) getByRole('textbox', { name: /username/i }) -
getByLabelText:用于带标签的表单字段javascript
getByLabelText(/email address/i) getByLabelText('Password') -
getByPlaceholderText:用于带占位符的输入框javascript
getByPlaceholderText(/search/i) -
getByText:用于带文本的非交互式元素javascript
getByText(/welcome/i) getByText('Error: Invalid credentials') -
getByDisplayValue:用于带值的表单元素javascript
getByDisplayValue('John Doe') -
getByAltText:用于带替代文本的图片javascript
getByAltText(/profile picture/i) -
getByTitle:用于带title属性的元素javascript
getByTitle(/close/i) -
getByTestId:其他查询方法无效时的最后选择javascript
getByTestId('custom-element')
Query Variants
查询变体
javascript
// Single element queries
screen.getByRole('button') // Throws if not found or multiple found
screen.queryByRole('button') // Returns null if not found
await screen.findByRole('button') // Async, waits up to 1000ms
// Multiple element queries
screen.getAllByRole('listitem') // Throws if none found
screen.queryAllByRole('listitem') // Returns [] if none found
await screen.findAllByRole('listitem') // Async versionjavascript
// 单个元素查询
screen.getByRole('button') // 未找到或找到多个时抛出错误
screen.queryByRole('button') // 未找到时返回null
await screen.findByRole('button') // 异步方法,最多等待1000ms
// 多个元素查询
screen.getAllByRole('listitem') // 未找到任何元素时抛出错误
screen.queryAllByRole('listitem') // 未找到时返回[]
await screen.findAllByRole('listitem') // 异步版本Component Testing Strategies
组件测试策略
Basic Component Test
基础组件测试
javascript
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting Component', () => {
it('renders greeting message', () => {
render(<Greeting name="Alice" />);
expect(screen.getByText(/hello, alice/i)).toBeInTheDocument();
});
it('renders default greeting when no name provided', () => {
render(<Greeting />);
expect(screen.getByText(/hello, guest/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import { Greeting } from './Greeting';
describe('Greeting Component', () => {
it('渲染问候消息', () => {
render(<Greeting name="Alice" />);
expect(screen.getByText(/hello, alice/i)).toBeInTheDocument();
});
it('未提供名称时渲染默认问候语', () => {
render(<Greeting />);
expect(screen.getByText(/hello, guest/i)).toBeInTheDocument();
});
});Testing User Interactions
测试用户交互
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter Component', () => {
it('increments counter on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
const count = screen.getByText(/count: 0/i);
expect(count).toBeInTheDocument();
await user.click(button);
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
it('decrements counter on button click', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
const decrementBtn = screen.getByRole('button', { name: /decrement/i });
await user.click(decrementBtn);
expect(screen.getByText(/count: 4/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';
describe('Counter Component', () => {
it('点击按钮时增加计数器值', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
const count = screen.getByText(/count: 0/i);
expect(count).toBeInTheDocument();
await user.click(button);
expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});
it('点击按钮时减少计数器值', async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
const decrementBtn = screen.getByRole('button', { name: /decrement/i });
await user.click(decrementBtn);
expect(screen.getByText(/count: 4/i)).toBeInTheDocument();
});
});Testing Forms
测试表单
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm Component', () => {
it('submits form with username and password', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
const usernameInput = screen.getByLabelText(/username/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.type(usernameInput, 'testuser');
await user.type(passwordInput, 'password123');
await user.click(submitButton);
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123',
});
});
it('shows validation errors for empty fields', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm Component', () => {
it('提交包含用户名和密码的表单', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
const usernameInput = screen.getByLabelText(/username/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.type(usernameInput, 'testuser');
await user.type(passwordInput, 'password123');
await user.click(submitButton);
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123',
});
});
it('为空字段显示验证错误', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
const submitButton = screen.getByRole('button', { name: /submit/i });
await user.click(submitButton);
expect(screen.getByText(/username is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
});Testing Conditional Rendering
测试条件渲染
javascript
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile Component', () => {
it('shows loading state when loading', () => {
render(<UserProfile loading={true} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('shows user data when loaded', () => {
const user = {
name: 'John Doe',
email: 'john@example.com',
};
render(<UserProfile loading={false} user={user} />);
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: /john doe/i })).toBeInTheDocument();
expect(screen.getByText(/john@example.com/i)).toBeInTheDocument();
});
it('shows error message when error occurs', () => {
render(<UserProfile loading={false} error="Failed to load user" />);
expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';
describe('UserProfile Component', () => {
it('加载时显示加载状态', () => {
render(<UserProfile loading={true} />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('加载完成后显示用户数据', () => {
const user = {
name: 'John Doe',
email: 'john@example.com',
};
render(<UserProfile loading={false} user={user} />);
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: /john doe/i })).toBeInTheDocument();
expect(screen.getByText(/john@example.com/i)).toBeInTheDocument();
});
it('发生错误时显示错误消息', () => {
render(<UserProfile loading={false} error="Failed to load user" />);
expect(screen.getByText(/failed to load user/i)).toBeInTheDocument();
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
});Mocking Patterns
模拟模式
Mocking Modules
模拟模块
Automatic Mock:
javascript
// __mocks__/axios.js
export default {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
};Usage in test:
javascript
import axios from 'axios';
import { UserService } from './UserService';
jest.mock('axios');
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches users successfully', async () => {
const mockUsers = [{ id: 1, name: 'Alice' }];
axios.get.mockResolvedValue({ data: mockUsers });
const users = await UserService.getUsers();
expect(axios.get).toHaveBeenCalledWith('/api/users');
expect(users).toEqual(mockUsers);
});
it('handles fetch error', async () => {
axios.get.mockRejectedValue(new Error('Network Error'));
await expect(UserService.getUsers()).rejects.toThrow('Network Error');
});
});自动模拟:
javascript
// __mocks__/axios.js
export default {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
};测试中的用法:
javascript
import axios from 'axios';
import { UserService } from './UserService';
jest.mock('axios');
describe('UserService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('成功获取用户', async () => {
const mockUsers = [{ id: 1, name: 'Alice' }];
axios.get.mockResolvedValue({ data: mockUsers });
const users = await UserService.getUsers();
expect(axios.get).toHaveBeenCalledWith('/api/users');
expect(users).toEqual(mockUsers);
});
it('处理获取数据时的错误', async () => {
axios.get.mockRejectedValue(new Error('Network Error'));
await expect(UserService.getUsers()).rejects.toThrow('Network Error');
});
});Mocking Functions
模拟函数
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button Component', () => {
it('calls onClick handler when clicked', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('calls onClick with event object', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
})
);
});
});javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button Component', () => {
it('点击时调用onClick处理函数', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('调用onClick时传入事件对象', async () => {
const user = userEvent.setup();
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
})
);
});
});Mocking API Calls with MSW (Mock Service Worker)
使用MSW(Mock Service Worker)模拟API调用
Setup MSW:
javascript
// src/mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
);
}),
rest.post('/api/users', async (req, res, ctx) => {
const { name, email } = await req.json();
return res(
ctx.status(201),
ctx.json({
id: 3,
name,
email,
})
);
}),
];Setup server:
javascript
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);Configure in setupTests.js:
javascript
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Usage in tests:
javascript
import { render, screen, waitFor } from '@testing-library/react';
import { server } from './mocks/server';
import { rest } from 'msw';
import { UserList } from './UserList';
describe('UserList Component', () => {
it('fetches and displays users', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/alice/i)).toBeInTheDocument();
expect(screen.getByText(/bob/i)).toBeInTheDocument();
});
});
it('handles server error', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal Server Error' })
);
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
});
});
});配置MSW:
javascript
// src/mocks/handlers.js
import { rest } from 'msw';
export const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
])
);
}),
rest.post('/api/users', async (req, res, ctx) => {
const { name, email } = await req.json();
return res(
ctx.status(201),
ctx.json({
id: 3,
name,
email,
})
);
}),
];配置服务器:
javascript
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);在setupTests.js中配置:
javascript
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());在测试中使用:
javascript
import { render, screen, waitFor } from '@testing-library/react';
import { server } from './mocks/server';
import { rest } from 'msw';
import { UserList } from './UserList';
describe('UserList Component', () => {
it('获取并显示用户', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/alice/i)).toBeInTheDocument();
expect(screen.getByText(/bob/i)).toBeInTheDocument();
});
});
it('处理服务器错误', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal Server Error' })
);
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
});
});
});Mocking Context
模拟上下文
javascript
import { render, screen } from '@testing-library/react';
import { AuthContext } from './AuthContext';
import { ProtectedComponent } from './ProtectedComponent';
const mockAuthContext = (overrides = {}) => ({
user: { id: 1, name: 'Test User' },
isAuthenticated: true,
login: jest.fn(),
logout: jest.fn(),
...overrides,
});
describe('ProtectedComponent', () => {
it('renders content for authenticated user', () => {
const contextValue = mockAuthContext();
render(
<AuthContext.Provider value={contextValue}>
<ProtectedComponent />
</AuthContext.Provider>
);
expect(screen.getByText(/welcome, test user/i)).toBeInTheDocument();
});
it('renders login prompt for unauthenticated user', () => {
const contextValue = mockAuthContext({
user: null,
isAuthenticated: false,
});
render(
<AuthContext.Provider value={contextValue}>
<ProtectedComponent />
</AuthContext.Provider>
);
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import { AuthContext } from './AuthContext';
import { ProtectedComponent } from './ProtectedComponent';
const mockAuthContext = (overrides = {}) => ({
user: { id: 1, name: 'Test User' },
isAuthenticated: true,
login: jest.fn(),
logout: jest.fn(),
...overrides,
});
describe('ProtectedComponent', () => {
it('为已认证用户渲染内容', () => {
const contextValue = mockAuthContext();
render(
<AuthContext.Provider value={contextValue}>
<ProtectedComponent />
</AuthContext.Provider>
);
expect(screen.getByText(/welcome, test user/i)).toBeInTheDocument();
});
it('为未认证用户渲染登录提示', () => {
const contextValue = mockAuthContext({
user: null,
isAuthenticated: false,
});
render(
<AuthContext.Provider value={contextValue}>
<ProtectedComponent />
</AuthContext.Provider>
);
expect(screen.getByText(/please log in/i)).toBeInTheDocument();
});
});Mocking Child Components
模拟子组件
javascript
import { render, screen } from '@testing-library/react';
import { ParentComponent } from './ParentComponent';
// Mock the child component
jest.mock('./ChildComponent', () => ({
ChildComponent: ({ title, onAction }) => (
<div>
<h2>{title}</h2>
<button onClick={onAction}>Mocked Action</button>
</div>
),
}));
describe('ParentComponent', () => {
it('renders with mocked child', () => {
render(<ParentComponent />);
expect(screen.getByText(/mocked action/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import { ParentComponent } from './ParentComponent';
// 模拟子组件
jest.mock('./ChildComponent', () => ({
ChildComponent: ({ title, onAction }) => (
<div>
<h2>{title}</h2>
<button onClick={onAction}>Mocked Action</button>
</div>
),
}));
describe('ParentComponent', () => {
it('使用模拟的子组件渲染', () => {
render(<ParentComponent />);
expect(screen.getByText(/mocked action/i)).toBeInTheDocument();
});
});Async Testing Patterns
异步测试模式
Testing with waitFor
使用waitFor测试
javascript
import { render, screen, waitFor } from '@testing-library/react';
import { AsyncComponent } from './AsyncComponent';
describe('AsyncComponent', () => {
it('loads and displays data', async () => {
render(<AsyncComponent />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
});
it('waits for specific condition', async () => {
render(<AsyncComponent />);
await waitFor(
() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
},
{ timeout: 3000 }
);
});
});javascript
import { render, screen, waitFor } from '@testing-library/react';
import { AsyncComponent } from './AsyncComponent';
describe('AsyncComponent', () => {
it('加载并显示数据', async () => {
render(<AsyncComponent />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});
});
it('等待特定条件满足', async () => {
render(<AsyncComponent />);
await waitFor(
() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
},
{ timeout: 3000 }
);
});
});Testing with findBy Queries
使用findBy查询测试
javascript
import { render, screen } from '@testing-library/react';
import { DataFetcher } from './DataFetcher';
describe('DataFetcher Component', () => {
it('displays fetched data', async () => {
render(<DataFetcher />);
// findBy automatically waits for element to appear
const heading = await screen.findByRole('heading', { name: /data/i });
expect(heading).toBeInTheDocument();
});
it('handles timeout for missing elements', async () => {
render(<DataFetcher url="/api/missing" />);
await expect(
screen.findByText(/success/i, {}, { timeout: 500 })
).rejects.toThrow();
});
});javascript
import { render, screen } from '@testing-library/react';
import { DataFetcher } from './DataFetcher';
describe('DataFetcher Component', () => {
it('显示获取到的数据', async () => {
render(<DataFetcher />);
// findBy会自动等待元素出现
const heading = await screen.findByRole('heading', { name: /data/i });
expect(heading).toBeInTheDocument();
});
it('等待不存在的元素时超时', async () => {
render(<DataFetcher url="/api/missing" />);
await expect(
screen.findByText(/success/i, {}, { timeout: 500 })
).rejects.toThrow();
});
});Testing Promises
测试Promise
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsyncForm } from './AsyncForm';
describe('AsyncForm Component', () => {
it('submits form and shows success message', async () => {
const user = userEvent.setup();
render(<AsyncForm />);
const input = screen.getByLabelText(/name/i);
const submitBtn = screen.getByRole('button', { name: /submit/i });
await user.type(input, 'John Doe');
await user.click(submitBtn);
const successMsg = await screen.findByText(/submitted successfully/i);
expect(successMsg).toBeInTheDocument();
});
it('shows error message on failure', async () => {
const user = userEvent.setup();
render(<AsyncForm shouldFail={true} />);
const submitBtn = screen.getByRole('button', { name: /submit/i });
await user.click(submitBtn);
const errorMsg = await screen.findByRole('alert');
expect(errorMsg).toHaveTextContent(/submission failed/i);
});
});javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AsyncForm } from './AsyncForm';
describe('AsyncForm Component', () => {
it('提交表单并显示成功消息', async () => {
const user = userEvent.setup();
render(<AsyncForm />);
const input = screen.getByLabelText(/name/i);
const submitBtn = screen.getByRole('button', { name: /submit/i });
await user.type(input, 'John Doe');
await user.click(submitBtn);
const successMsg = await screen.findByText(/submitted successfully/i);
expect(successMsg).toBeInTheDocument();
});
it('提交失败时显示错误消息', async () => {
const user = userEvent.setup();
render(<AsyncForm shouldFail={true} />);
const submitBtn = screen.getByRole('button', { name: /submit/i });
await user.click(submitBtn);
const errorMsg = await screen.findByRole('alert');
expect(errorMsg).toHaveTextContent(/submission failed/i);
});
});Testing with Fake Timers
使用假定时器测试
javascript
import { render, screen, act } from '@testing-library/react';
import { Timer } from './Timer';
describe('Timer Component', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('updates timer every second', () => {
render(<Timer />);
expect(screen.getByText(/0 seconds/i)).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText(/1 second/i)).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText(/4 seconds/i)).toBeInTheDocument();
});
it('cleans up timer on unmount', () => {
const { unmount } = render(<Timer />);
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});javascript
import { render, screen, act } from '@testing-library/react';
import { Timer } from './Timer';
describe('Timer Component', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('每秒更新定时器', () => {
render(<Timer />);
expect(screen.getByText(/0 seconds/i)).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(1000);
});
expect(screen.getByText(/1 second/i)).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText(/4 seconds/i)).toBeInTheDocument();
});
it('卸载时清理定时器', () => {
const { unmount } = render(<Timer />);
const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});Testing Custom Hooks
测试自定义Hooks
Basic Hook Testing
基础Hook测试
javascript
import { renderHook } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('initializes with provided value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
});javascript
import { renderHook } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('使用默认值初始化', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
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.decrement();
});
expect(result.current.count).toBe(4);
});
});Testing Hooks with Props
测试带Props的Hooks
javascript
import { renderHook } from '@testing-library/react';
import { useFetch } from './useFetch';
describe('useFetch Hook', () => {
it('fetches data for given URL', 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).toBeDefined();
expect(result.current.error).toBeNull();
});
it('refetches when URL changes', async () => {
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/users' } }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const firstData = result.current.data;
rerender({ url: '/api/posts' });
await waitFor(() => {
expect(result.current.data).not.toEqual(firstData);
});
});
});javascript
import { renderHook } from '@testing-library/react';
import { useFetch } from './useFetch';
describe('useFetch Hook', () => {
it('为给定URL获取数据', 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).toBeDefined();
expect(result.current.error).toBeNull();
});
it('URL变化时重新获取数据', async () => {
const { result, rerender } = renderHook(
({ url }) => useFetch(url),
{ initialProps: { url: '/api/users' } }
);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
const firstData = result.current.data;
rerender({ url: '/api/posts' });
await waitFor(() => {
expect(result.current.data).not.toEqual(firstData);
});
});
});Testing Hooks with Context
测试带上下文的Hooks
javascript
import { renderHook } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { useTheme } from './useTheme';
describe('useTheme Hook', () => {
const wrapper = ({ children }) => (
<ThemeProvider initialTheme="light">
{children}
</ThemeProvider>
);
it('returns current theme', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
});
it('toggles theme', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('dark');
});
});javascript
import { renderHook } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import { useTheme } from './useTheme';
describe('useTheme Hook', () => {
const wrapper = ({ children }) => (
<ThemeProvider initialTheme="light">
{children}
</ThemeProvider>
);
it('返回当前主题', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe('light');
});
it('切换主题', () => {
const { result } = renderHook(() => useTheme(), { wrapper });
act(() => {
result.current.toggleTheme();
});
expect(result.current.theme).toBe('dark');
});
});Testing Async Hooks
测试异步Hooks
javascript
import { renderHook, waitFor } from '@testing-library/react';
import { useAsyncData } from './useAsyncData';
describe('useAsyncData Hook', () => {
it('loads data asynchronously', async () => {
const { result } = renderHook(() => useAsyncData('/api/data'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});
it('handles errors', async () => {
const { result } = renderHook(() => useAsyncData('/api/error'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeNull();
});
});javascript
import { renderHook, waitFor } from '@testing-library/react';
import { useAsyncData } from './useAsyncData';
describe('useAsyncData Hook', () => {
it('异步加载数据', async () => {
const { result } = renderHook(() => useAsyncData('/api/data'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBeDefined();
});
it('处理错误', async () => {
const { result } = renderHook(() => useAsyncData('/api/error'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeNull();
});
});Integration Testing Patterns
集成测试模式
Testing Component Integration
测试组件集成
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from './App';
describe('App Integration', () => {
it('navigates between pages', async () => {
const user = userEvent.setup();
render(<App />);
expect(screen.getByText(/home page/i)).toBeInTheDocument();
const aboutLink = screen.getByRole('link', { name: /about/i });
await user.click(aboutLink);
expect(screen.getByText(/about page/i)).toBeInTheDocument();
});
it('completes full user flow', async () => {
const user = userEvent.setup();
render(<App />);
// Navigate to signup
await user.click(screen.getByRole('link', { name: /sign up/i }));
// Fill out form
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// Submit form
await user.click(screen.getByRole('button', { name: /submit/i }));
// Verify success
expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from './App';
describe('App Integration', () => {
it('在页面之间导航', async () => {
const user = userEvent.setup();
render(<App />);
expect(screen.getByText(/home page/i)).toBeInTheDocument();
const aboutLink = screen.getByRole('link', { name: /about/i });
await user.click(aboutLink);
expect(screen.getByText(/about page/i)).toBeInTheDocument();
});
it('完成完整用户流程', async () => {
const user = userEvent.setup();
render(<App />);
// 导航到注册页面
await user.click(screen.getByRole('link', { name: /sign up/i }));
// 填写表单
await user.type(screen.getByLabelText(/email/i), 'user@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
// 提交表单
await user.click(screen.getByRole('button', { name: /submit/i }));
// 验证成功
expect(await screen.findByText(/welcome/i)).toBeInTheDocument();
});
});Testing with Router
测试路由
javascript
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { AppRoutes } from './AppRoutes';
const renderWithRouter = (ui, { initialEntries = ['/'] } = {}) => {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
);
};
describe('AppRoutes Integration', () => {
it('renders home page by default', () => {
renderWithRouter(<AppRoutes />);
expect(screen.getByText(/home/i)).toBeInTheDocument();
});
it('renders user page at /users/:id', () => {
renderWithRouter(<AppRoutes />, { initialEntries: ['/users/123'] });
expect(screen.getByText(/user profile/i)).toBeInTheDocument();
});
it('navigates programmatically', async () => {
const user = userEvent.setup();
renderWithRouter(<AppRoutes />);
const navButton = screen.getByRole('button', { name: /go to profile/i });
await user.click(navButton);
expect(screen.getByText(/profile page/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { AppRoutes } from './AppRoutes';
const renderWithRouter = (ui, { initialEntries = ['/'] } = {}) => {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
);
};
describe('AppRoutes Integration', () => {
it('默认渲染首页', () => {
renderWithRouter(<AppRoutes />);
expect(screen.getByText(/home/i)).toBeInTheDocument();
});
it('在/users/:id路径渲染用户页面', () => {
renderWithRouter(<AppRoutes />, { initialEntries: ['/users/123'] });
expect(screen.getByText(/user profile/i)).toBeInTheDocument();
});
it('编程式导航', async () => {
const user = userEvent.setup();
renderWithRouter(<AppRoutes />);
const navButton = screen.getByRole('button', { name: /go to profile/i });
await user.click(navButton);
expect(screen.getByText(/profile page/i)).toBeInTheDocument();
});
});Testing with Redux
测试Redux
javascript
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userEvent from '@testing-library/user-event';
import { TodoList } from './TodoList';
import todosReducer from './todosSlice';
const createMockStore = (initialState = {}) => {
return configureStore({
reducer: {
todos: todosReducer,
},
preloadedState: initialState,
});
};
const renderWithStore = (ui, { store = createMockStore() } = {}) => {
return render(<Provider store={store}>{ui}</Provider>);
};
describe('TodoList Integration', () => {
it('adds new todo', async () => {
const user = userEvent.setup();
renderWithStore(<TodoList />);
const input = screen.getByPlaceholderText(/new todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'Buy groceries');
await user.click(addButton);
expect(screen.getByText(/buy groceries/i)).toBeInTheDocument();
});
it('renders initial todos from store', () => {
const initialState = {
todos: {
items: [
{ id: 1, text: 'Existing todo', completed: false },
],
},
};
renderWithStore(<TodoList />, { store: createMockStore(initialState) });
expect(screen.getByText(/existing todo/i)).toBeInTheDocument();
});
});javascript
import { render, screen } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userEvent from '@testing-library/user-event';
import { TodoList } from './TodoList';
import todosReducer from './todosSlice';
const createMockStore = (initialState = {}) => {
return configureStore({
reducer: {
todos: todosReducer,
},
preloadedState: initialState,
});
};
const renderWithStore = (ui, { store = createMockStore() } = {}) => {
return render(<Provider store={store}>{ui}</Provider>);
};
describe('TodoList Integration', () => {
it('添加新待办事项', async () => {
const user = userEvent.setup();
renderWithStore(<TodoList />);
const input = screen.getByPlaceholderText(/new todo/i);
const addButton = screen.getByRole('button', { name: /add/i });
await user.type(input, 'Buy groceries');
await user.click(addButton);
expect(screen.getByText(/buy groceries/i)).toBeInTheDocument();
});
it('渲染来自store的初始待办事项', () => {
const initialState = {
todos: {
items: [
{ id: 1, text: 'Existing todo', completed: false },
],
},
};
renderWithStore(<TodoList />, { store: createMockStore(initialState) });
expect(screen.getByText(/existing todo/i)).toBeInTheDocument();
});
});Jest DOM Matchers
Jest DOM 匹配器
Common Matchers
常用匹配器
javascript
// Element presence
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();
// Visibility
expect(element).toBeVisible();
expect(element).not.toBeVisible();
// Text content
expect(element).toHaveTextContent('Hello World');
expect(element).toHaveTextContent(/hello/i);
// Attributes
expect(element).toHaveAttribute('type', 'submit');
expect(element).toHaveAttribute('disabled');
// Classes
expect(element).toHaveClass('active');
expect(element).toHaveClass('btn', 'btn-primary');
// Styles
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveStyle('display: none');
// Forms
expect(input).toHaveValue('test');
expect(input).toHaveDisplayValue('Test');
expect(checkbox).toBeChecked();
expect(checkbox).not.toBeChecked();
expect(input).toBeDisabled();
expect(input).toBeEnabled();
expect(input).toBeRequired();
expect(input).toBeInvalid();
expect(input).toBeValid();
// Focus
expect(element).toHaveFocus();
// Accessibility
expect(element).toHaveAccessibleName('Submit button');
expect(element).toHaveAccessibleDescription('Click to submit form');
// Contains
expect(container).toContainElement(child);
expect(container).toContainHTML('<span>Text</span>');javascript
// 元素存在性
expect(element).toBeInTheDocument();
expect(element).not.toBeInTheDocument();
// 可见性
expect(element).toBeVisible();
expect(element).not.toBeVisible();
// 文本内容
expect(element).toHaveTextContent('Hello World');
expect(element).toHaveTextContent(/hello/i);
// 属性
expect(element).toHaveAttribute('type', 'submit');
expect(element).toHaveAttribute('disabled');
// 类名
expect(element).toHaveClass('active');
expect(element).toHaveClass('btn', 'btn-primary');
// 样式
expect(element).toHaveStyle({ color: 'red' });
expect(element).toHaveStyle('display: none');
// 表单
expect(input).toHaveValue('test');
expect(input).toHaveDisplayValue('Test');
expect(checkbox).toBeChecked();
expect(checkbox).not.toBeChecked();
expect(input).toBeDisabled();
expect(input).toBeEnabled();
expect(input).toBeRequired();
expect(input).toBeInvalid();
expect(input).toBeValid();
// 焦点
expect(element).toHaveFocus();
// 可访问性
expect(element).toHaveAccessibleName('Submit button');
expect(element).toHaveAccessibleDescription('Click to submit form');
// 包含关系
expect(container).toContainElement(child);
expect(container).toContainHTML('<span>Text</span>');Best Practices
最佳实践
Test Organization
测试组织
-
Group Related Tests: Useblocks to organize tests
describejavascriptdescribe('UserProfile', () => { describe('when loading', () => { it('shows loading spinner', () => {}); }); describe('when loaded', () => { it('displays user information', () => {}); it('shows profile picture', () => {}); }); }); -
Use Descriptive Test Names: Test names should describe behaviorjavascript
// Good it('displays error message when login fails', () => {}); // Bad it('test login', () => {}); -
Follow AAA Pattern: Arrange, Act, Assertjavascript
it('increments counter', async () => { // Arrange const user = userEvent.setup(); render(<Counter />); // Act await user.click(screen.getByRole('button', { name: /increment/i })); // Assert expect(screen.getByText(/count: 1/i)).toBeInTheDocument(); });
-
分组相关测试:使用块组织测试
describejavascriptdescribe('UserProfile', () => { describe('加载时', () => { it('显示加载动画', () => {}); }); describe('加载完成后', () => { it('显示用户信息', () => {}); it('显示头像', () => {}); }); }); -
使用描述性的测试名称:测试名称应描述行为javascript
// 好的命名 it('登录失败时显示错误消息', () => {}); // 不好的命名 it('测试登录', () => {}); -
遵循AAA模式:准备(Arrange)、执行(Act)、断言(Assert)javascript
it('增加计数器值', async () => { // 准备 const user = userEvent.setup(); render(<Counter />); // 执行 await user.click(screen.getByRole('button', { name: /increment/i })); // 断言 expect(screen.getByText(/count: 1/i)).toBeInTheDocument(); });
Query Best Practices
查询最佳实践
- Prefer Accessible Queries: Use getByRole, getByLabelText
- Use Screen Queries: Import from screen instead of destructuring render
- Avoid getByTestId: Use it as last resort only
- Use Regular Expressions: More flexible than exact strings
- 优先使用可访问的查询方法:使用getByRole、getByLabelText
- 使用Screen查询:从screen导入查询方法,而非从render解构
- 避免使用getByTestId:仅在其他查询方法无效时使用
- 使用正则表达式:比精确字符串更灵活
Async Testing Best Practices
异步测试最佳实践
- Use findBy for Async: Prefer findBy over getBy + waitFor
- Set Proper Timeouts: Configure waitFor timeouts for slow operations
- Avoid act() Warnings: Use userEvent, waitFor, findBy appropriately
- Clean Up Timers: Use jest.useFakeTimers() and cleanup properly
- 使用findBy处理异步场景:优先使用findBy而非getBy + waitFor
- 设置适当的超时时间:为慢操作配置waitFor超时时间
- 避免act()警告:正确使用userEvent、waitFor、findBy
- 清理定时器:正确使用jest.useFakeTimers()并清理
Mocking Best Practices
模拟最佳实践
- Mock at Right Level: Mock external dependencies, not internal logic
- Reset Mocks: Clear mocks between tests
- Use MSW for API: Prefer MSW over mocking axios/fetch directly
- Avoid Over-Mocking: Don't mock what you're testing
- 在正确层级模拟:模拟外部依赖,而非内部逻辑
- 重置模拟:在测试之间清除模拟
- 使用MSW模拟API:优先使用MSW而非直接模拟axios/fetch
- 避免过度模拟:不要模拟你要测试的内容
Coverage Best Practices
覆盖率最佳实践
- Focus on Behavior: Test user-facing behavior, not implementation
- Don't Chase 100%: Focus on critical paths
- Test Error States: Include error handling tests
- Test Edge Cases: Include boundary conditions
- 专注于行为:测试用户可见的行为,而非实现细节
- 不追求100%覆盖率:专注于关键路径
- 测试错误状态:包含错误处理的测试
- 测试边界情况:包含边界条件测试
Common Testing Patterns
常见测试模式
Testing Lists and Iterations
测试列表和迭代
javascript
it('renders list of items', () => {
const items = ['Apple', 'Banana', 'Cherry'];
render(<ItemList items={items} />);
items.forEach(item => {
expect(screen.getByText(item)).toBeInTheDocument();
});
});javascript
it('渲染项目列表', () => {
const items = ['Apple', 'Banana', 'Cherry'];
render(<ItemList items={items} />);
items.forEach(item => {
expect(screen.getByText(item)).toBeInTheDocument();
});
});Testing Accessibility
测试可访问性
javascript
it('has accessible form', () => {
render(<ContactForm />);
const nameInput = screen.getByLabelText(/name/i);
expect(nameInput).toHaveAccessibleName('Name');
expect(nameInput).toBeRequired();
const submitButton = screen.getByRole('button', { name: /submit/i });
expect(submitButton).toHaveAttribute('type', 'submit');
});javascript
it('表单符合可访问性标准', () => {
render(<ContactForm />);
const nameInput = screen.getByLabelText(/name/i);
expect(nameInput).toHaveAccessibleName('Name');
expect(nameInput).toBeRequired();
const submitButton = screen.getByRole('button', { name: /submit/i });
expect(submitButton).toHaveAttribute('type', 'submit');
});Testing Error Boundaries
测试错误边界
javascript
it('catches errors and displays fallback', () => {
const ThrowError = () => {
throw new Error('Test error');
};
// Suppress console.error for this test
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowError />
</ErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
spy.mockRestore();
});javascript
it('捕获错误并显示回退内容', () => {
const ThrowError = () => {
throw new Error('Test error');
};
// 在此测试中抑制console.error
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<div>出现错误</div>}>
<ThrowError />
</ErrorBoundary>
);
expect(screen.getByText(/出现错误/i)).toBeInTheDocument();
spy.mockRestore();
});Testing Portals
测试Portals
javascript
it('renders modal in portal', () => {
render(<Modal isOpen={true}>Modal Content</Modal>);
const modal = screen.getByText(/modal content/i);
expect(modal).toBeInTheDocument();
// Modal should be in document.body, not in the component tree
expect(modal.parentElement).toBe(document.body);
});javascript
it('在Portal中渲染模态框', () => {
render(<Modal isOpen={true}>Modal Content</Modal>);
const modal = screen.getByText(/modal content/i);
expect(modal).toBeInTheDocument();
// 模态框应在document.body中,而非组件树内
expect(modal.parentElement).toBe(document.body);
});Troubleshooting
故障排除
Common Issues
常见问题
Issue: "Unable to find element"
- Solution: Use screen.debug() to see DOM, check query type, wait for async updates
Issue: "Act warnings"
- Solution: Use userEvent instead of fireEvent, wrap state updates in act(), use waitFor/findBy
Issue: "Jest timeout"
- Solution: Increase timeout, check for infinite loops, ensure async operations complete
Issue: "Cannot read property of undefined"
- Solution: Check mocks are set up correctly, ensure components receive required props
Issue: "Multiple elements found"
- Solution: Make queries more specific, use getAllBy for multiple elements
问题:“无法找到元素”
- 解决方案:使用screen.debug()查看DOM,检查查询类型,等待异步更新
问题:“act警告”
- 解决方案:使用userEvent而非fireEvent,将状态更新包裹在act()中,正确使用waitFor/findBy
问题:“Jest超时”
- 解决方案:增加超时时间,检查是否存在无限循环,确保异步操作完成
问题:“无法读取未定义的属性”
- 解决方案:检查模拟是否正确设置,确保组件接收到所需的Props
问题:“找到多个元素”
- 解决方案:使查询更具体,对多个元素使用getAllBy
Resources
资源
- Jest Documentation: https://jestjs.io/
- React Testing Library: https://testing-library.com/react
- Testing Library Queries: https://testing-library.com/docs/queries/about
- Jest DOM Matchers: https://github.com/testing-library/jest-dom
- MSW Documentation: https://mswjs.io/
- Common Mistakes: https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
Skill Version: 1.0.0
Last Updated: October 2025
Skill Category: Testing, React, Quality Assurance
Compatible With: Jest 29+, React Testing Library 13+, React 16.8+
- Jest文档:https://jestjs.io/
- React Testing Library:https://testing-library.com/react
- Testing Library查询方法:https://testing-library.com/docs/queries/about
- Jest DOM匹配器:https://github.com/testing-library/jest-dom
- MSW文档:https://mswjs.io/
- 常见错误:https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
技能版本:1.0.0
最后更新:2025年10月
技能分类:测试、React、质量保证
兼容版本:Jest 29+、React Testing Library 13+、React 16.8+