react-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseReact Testing
React 测试
Overview
概述
This skill provides comprehensive guidance for testing React applications using Vitest, React Testing Library, and Jest. Apply these patterns when writing unit tests, integration tests, and ensuring code quality.
本技能提供了使用Vitest、React Testing Library和Jest测试React应用的全面指引。编写单元测试、集成测试以及保障代码质量时可遵循这些模式。
Core Philosophy
核心理念
- Test behavior, not implementation: Focus on what users see and do
- Avoid testing internal state: Test public APIs and user interactions
- Write tests that give confidence: Catch real bugs, not false positives
- Keep tests simple: Tests should be easier to understand than the code
- 测试行为而非实现细节:聚焦用户所见及操作
- 避免测试内部状态:测试公开API与用户交互
- 编写能带来信心的测试:捕获真实Bug,而非误报
- 保持测试简洁:测试应比代码更易理解
Testing Tools
测试工具
Vitest Configuration
Vitest 配置
ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/']
}
}
});ts
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/']
}
}
});Test Setup
测试环境配置
ts
// src/test/setup.ts
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});ts
// src/test/setup.ts
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
afterEach(() => {
cleanup();
});Component Testing
组件测试
Basic Component Test
基础组件测试
tsx
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { Button } from './Button';
test('renders button with label', () => {
render(<Button label="Click me" onClick={vi.fn()} />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button label="Click me" onClick={handleClick} />);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when prop is set', () => {
render(<Button label="Click me" onClick={vi.fn()} disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});tsx
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import { Button } from './Button';
test('renders button with label', () => {
render(<Button label="Click me" onClick={vi.fn()} />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const handleClick = vi.fn();
render(<Button label="Click me" onClick={handleClick} />);
await userEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when prop is set', () => {
render(<Button label="Click me" onClick={vi.fn()} disabled />);
expect(screen.getByRole('button')).toBeDisabled();
});Form Testing
表单测试
tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('submits form with email and password', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Login' }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
});
});
test('shows validation error for invalid email', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.type(screen.getByLabelText('Email'), 'invalid-email');
await userEvent.click(screen.getByRole('button', { name: 'Login' }));
expect(await screen.findByText('Invalid email address')).toBeInTheDocument();
});tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('submits form with email and password', async () => {
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
await userEvent.click(screen.getByRole('button', { name: 'Login' }));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123'
});
});
});
test('shows validation error for invalid email', async () => {
render(<LoginForm onSubmit={vi.fn()} />);
await userEvent.type(screen.getByLabelText('Email'), 'invalid-email');
await userEvent.click(screen.getByRole('button', { name: 'Login' }));
expect(await screen.findByText('Invalid email address')).toBeInTheDocument();
});Hook Testing
Hook测试
Custom Hook Test
自定义Hook测试
tsx
import { renderHook, act } from '@testing-library/react';
import { expect, test } from 'vitest';
import { useCounter } from './useCounter';
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(9);
});tsx
import { renderHook, act } from '@testing-library/react';
import { expect, test } from 'vitest';
import { useCounter } from './useCounter';
test('increments counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(9);
});Hook with Dependencies
带依赖的Hook测试
tsx
import { renderHook, waitFor } from '@testing-library/react';
import { useApi } from './useApi';
test('fetches data successfully', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ name: 'John' })
})
) as any;
const { result } = renderHook(() => useApi('/api/user'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'John' });
expect(result.current.error).toBeNull();
});
test('handles error', async () => {
global.fetch = vi.fn(() =>
Promise.reject(new Error('Network error'))
);
const { result } = renderHook(() => useApi('/api/user'));
await waitFor(() => {
expect(result.current.error).toBeTruthy();
});
expect(result.current.data).toBeUndefined();
});tsx
import { renderHook, waitFor } from '@testing-library/react';
import { useApi } from './useApi';
test('fetches data successfully', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ name: 'John' })
})
) as any;
const { result } = renderHook(() => useApi('/api/user'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: 'John' });
expect(result.current.error).toBeNull();
});
test('handles error', async () => {
global.fetch = vi.fn(() =>
Promise.reject(new Error('Network error'))
);
const { result } = renderHook(() => useApi('/api/user'));
await waitFor(() => {
expect(result.current.error).toBeTruthy();
});
expect(result.current.data).toBeUndefined();
});Integration Testing
集成测试
With Context
结合Context的测试
tsx
import { render, screen } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import { Dashboard } from './Dashboard';
const renderWithAuth = (ui: ReactElement, { user = null } = {}) => {
return render(
<AuthProvider value={{ user }}>
{ui}
</AuthProvider>
);
};
test('shows dashboard when authenticated', () => {
renderWithAuth(<Dashboard />, { user: { name: 'John' } });
expect(screen.getByText('Welcome, John')).toBeInTheDocument();
});
test('redirects to login when not authenticated', () => {
renderWithAuth(<Dashboard />);
expect(screen.queryByText('Welcome')).not.toBeInTheDocument();
});tsx
import { render, screen } from '@testing-library/react';
import { AuthProvider } from './AuthContext';
import { Dashboard } from './Dashboard';
const renderWithAuth = (ui: ReactElement, { user = null } = {}) => {
return render(
<AuthProvider value={{ user }}>
{ui}
</AuthProvider>
);
};
test('shows dashboard when authenticated', () => {
renderWithAuth(<Dashboard />, { user: { name: 'John' } });
expect(screen.getByText('Welcome, John')).toBeInTheDocument();
});
test('redirects to login when not authenticated', () => {
renderWithAuth(<Dashboard />);
expect(screen.queryByText('Welcome')).not.toBeInTheDocument();
});With Router
结合Router的测试
tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { App } from './App';
test('navigates to profile page', async () => {
render(
<MemoryRouter initialEntries={['/profile']}>
<App />
</MemoryRouter>
);
expect(screen.getByText('User Profile')).toBeInTheDocument();
});tsx
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { App } from './App';
test('navigates to profile page', async () => {
render(
<MemoryRouter initialEntries={['/profile']}>
<App />
</MemoryRouter>
);
expect(screen.getByText('User Profile')).toBeInTheDocument();
});Mocking
模拟(Mocking)
API Mocking
API模拟
tsx
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' }
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays users from API', async () => {
render(<UserList />);
expect(await screen.findByText('John')).toBeInTheDocument();
expect(await screen.findByText('Jane')).toBeInTheDocument();
});tsx
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' }
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays users from API', async () => {
render(<UserList />);
expect(await screen.findByText('John')).toBeInTheDocument();
expect(await screen.findByText('Jane')).toBeInTheDocument();
});Component Mocking
组件模拟
tsx
import { vi } from 'vitest';
vi.mock('./HeavyComponent', () => ({
HeavyComponent: () => <div>Mocked Component</div>
}));
test('renders page with mocked component', () => {
render(<Dashboard />);
expect(screen.getByText('Mocked Component')).toBeInTheDocument();
});tsx
import { vi } from 'vitest';
vi.mock('./HeavyComponent', () => ({
HeavyComponent: () => <div>Mocked Component</div>
}));
test('renders page with mocked component', () => {
render(<Dashboard />);
expect(screen.getByText('Mocked Component')).toBeInTheDocument();
});Accessibility Testing
可访问性测试
tsx
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('has no accessibility violations', async () => {
const { container } = render(<LoginForm onSubmit={vi.fn()} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});tsx
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('has no accessibility violations', async () => {
const { container } = render(<LoginForm onSubmit={vi.fn()} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Best Practices
最佳实践
- Use semantic queries: ,
getByRoleovergetByLabelTextgetByTestId - Test user behavior: Click, type, navigate like users do
- Avoid implementation details: Don't test state or props directly
- Use for async: Automatically waits for elements
findBy - Clean up: Use to reset DOM
afterEach(cleanup) - Mock external dependencies: APIs, timers, modules
- Test error states: Loading, error, empty states
- Keep tests focused: One assertion per test when possible
- Use descriptive test names: Describe what should happen
- Run tests in CI/CD: Ensure tests pass before deployment
- 使用语义化查询:、
getByRole优先于getByLabelTextgetByTestId - 测试用户行为:像用户一样点击、输入、导航
- 避免关注实现细节:不要直接测试状态或属性
- 异步场景使用:自动等待元素加载
findBy - 清理环境:使用重置DOM
afterEach(cleanup) - 模拟外部依赖:API、定时器、模块
- 测试错误状态:加载中、错误、空状态
- 保持测试聚焦:尽可能每个测试对应一个断言
- 使用描述性测试名称:明确描述预期结果
- 在CI/CD中运行测试:确保部署前测试全部通过