测试金字塔描述了不同层次测试的理想分布:更多的单元测试,较少的集成测试,更少的端到端测试。
测试完整应用流程,模拟真实用户行为
5-10% 测试覆盖测试组件间的交互和集成
15-20% 测试覆盖测试独立的函数、组件和Hook
70-80% 测试覆盖JavaScript测试框架,React默认测试工具
用于测试React组件的工具库
端到端测试框架
现代Web测试和自动化框架
Mock Service Worker - API Mocking库
测试React Hooks的工具
# 使用Create React App创建的应用已包含Jest和React Testing Library
npx create-react-app my-app --template typescript
# 手动安装
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event @testing-library/dom
# 安装类型定义(TypeScript)
npm install --save-dev @types/jest @types/testing-library__react
# 安装Cypress(端到端测试)
npm install --save-dev cypress
# 安装MSW(API Mocking)
npm install --save-dev msw
# package.json配置示例
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:e2e": "cypress run",
"test:e2e:open": "cypress open"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts",
"!src/index.js",
"!src/reportWebVitals.js"
]
}
}
// Button.jsx
import React from 'react';
const Button = ({ onClick, children, disabled = false }) => {
return (
<button
onClick={onClick}
disabled={disabled}
className="custom-button"
data-testid="custom-button"
>
{children}
</button>
);
};
export default Button;
// Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button组件', () => {
test('渲染按钮文本', () => {
render(<Button>点击我</Button>);
// 使用getByText查找元素
const buttonElement = screen.getByText(/点击我/i);
expect(buttonElement).toBeInTheDocument();
});
test('点击按钮触发回调', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>点击</Button>);
// 使用getByTestId查找元素
const button = screen.getByTestId('custom-button');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('禁用状态', () => {
render(<Button disabled={true}>禁用按钮</Button>);
const button = screen.getByTestId('custom-button');
expect(button).toBeDisabled();
expect(button).toHaveClass('custom-button');
});
test('快照测试', () => {
const { container } = render(<Button>保存</Button>);
expect(container.firstChild).toMatchSnapshot();
});
});
// LoginForm.jsx
import React, { useState } from 'react';
const LoginForm = ({ onSubmit }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!email) newErrors.email = '邮箱不能为空';
if (!password) newErrors.password = '密码不能为空';
if (password && password.length < 6) {
newErrors.password = '密码至少6位';
}
return newErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit} data-testid="login-form">
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
data-testid="email-input"
/>
{errors.email && (
<span data-testid="email-error">{errors.email}</span>
)}
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
data-testid="password-input"
/>
{errors.password && (
<span data-testid="password-error">{errors.password}</span>
)}
</div>
<button type="submit" data-testid="submit-button">
登录
</button>
</form>
);
};
export default LoginForm;
// LoginForm.test.js
import React from 'react';
import {
render,
screen,
fireEvent,
waitFor
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm组件', () => {
test('渲染表单元素', () => {
render(<LoginForm />);
expect(screen.getByTestId('login-form')).toBeInTheDocument();
expect(screen.getByTestId('email-input')).toBeInTheDocument();
expect(screen.getByTestId('password-input')).toBeInTheDocument();
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
});
test('表单提交成功', async () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
// 使用userEvent模拟用户输入
await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
// 提交表单
fireEvent.click(screen.getByTestId('submit-button'));
// 验证回调被调用
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
});
test('表单验证失败', async () => {
const mockSubmit = jest.fn();
render(<LoginForm onSubmit={mockSubmit} />);
// 只输入邮箱,不输入密码
await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
fireEvent.click(screen.getByTestId('submit-button'));
// 验证错误信息显示
await waitFor(() => {
expect(screen.getByTestId('password-error')).toBeInTheDocument();
expect(screen.getByTestId('password-error')).toHaveTextContent(
'密码不能为空'
);
});
expect(mockSubmit).not.toHaveBeenCalled();
});
test('密码长度验证', async () => {
render(<LoginForm />);
await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), '123');
fireEvent.click(screen.getByTestId('submit-button'));
await waitFor(() => {
expect(screen.getByTestId('password-error')).toHaveTextContent(
'密码至少6位'
);
});
});
});
// useCounter.js
import { useState, useCallback } from 'react';
const useCounter = (initialValue = 0) => {
const [count, setCount] = useState(initialValue);
const [history, setHistory] = useState([]);
const increment = useCallback(() => {
setCount(prev => {
const newCount = prev + 1;
setHistory(prevHistory => [...prevHistory, newCount]);
return newCount;
});
}, []);
const decrement = useCallback(() => {
setCount(prev => {
const newCount = prev - 1;
setHistory(prevHistory => [...prevHistory, newCount]);
return newCount;
});
}, []);
const reset = useCallback(() => {
setCount(initialValue);
setHistory([]);
}, [initialValue]);
return {
count,
history,
increment,
decrement,
reset
};
};
export default useCounter;
// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
describe('useCounter Hook', () => {
test('应该正确初始化计数', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
expect(result.current.history).toEqual([]);
});
test('增加计数应该工作', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
expect(result.current.history).toEqual([1]);
});
test('减少计数应该工作', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
expect(result.current.history).toEqual([4]);
});
test('重置计数应该工作', () => {
const { result } = renderHook(() => useCounter(3));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(3);
expect(result.current.history).toEqual([]);
});
test('多次操作应该记录历史', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
result.current.increment();
result.current.decrement();
});
expect(result.current.count).toBe(1);
expect(result.current.history).toEqual([1, 2, 1]);
});
});
// 使用Hook的组件测试
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
function CounterComponent() {
const { count, increment, decrement } = useCounter();
return (
<div>
<span data-testid="count-display">{count}</span>
<button data-testid="increment-btn" onClick={increment}>
增加
</button>
<button data-testid="decrement-btn" onClick={decrement}>
减少
</button>
</div>
);
}
describe('使用useCounter的组件', () => {
test('渲染初始计数', () => {
render(<CounterComponent />);
expect(screen.getByTestId('count-display')).toHaveTextContent('0');
});
test('点击按钮更新计数', () => {
render(<CounterComponent />);
fireEvent.click(screen.getByTestId('increment-btn'));
expect(screen.getByTestId('count-display')).toHaveTextContent('1');
fireEvent.click(screen.getByTestId('decrement-btn'));
expect(screen.getByTestId('count-display')).toHaveTextContent('0');
});
});
// UserProfile.jsx
import React, { useState, useEffect } from 'react';
import axios from 'axios';
const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const response = await axios.get(`/api/users/${userId}`);
setUser(response.data);
setError(null);
} catch (err) {
setError('获取用户信息失败');
console.error(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) {
return <div data-testid="loading-spinner">加载中...</div>;
}
if (error) {
return <div data-testid="error-message">{error}</div>;
}
return (
<div data-testid="user-profile">
<h3 data-testid="user-name">{user.name}</h3>
<p data-testid="user-email">邮箱: {user.email}</p>
<p data-testid="user-role">角色: {user.role}</p>
</div>
);
};
export default UserProfile;
// UserProfile.test.js
import React from 'react';
import {
render,
screen,
waitFor,
waitForElementToBeRemoved
} from '@testing-library/react';
import axios from 'axios';
import UserProfile from './UserProfile';
// Mock axios
jest.mock('axios');
describe('UserProfile组件', () => {
const mockUser = {
id: 1,
name: '张三',
email: 'zhangsan@example.com',
role: '管理员'
};
beforeEach(() => {
// 清除之前的mock调用
jest.clearAllMocks();
});
test('加载状态', () => {
axios.get.mockImplementation(() =>
new Promise(() => {}) // 永不resolve的Promise
);
render(<UserProfile userId={1} />);
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});
test('成功加载用户数据', async () => {
axios.get.mockResolvedValue({ data: mockUser });
render(<UserProfile userId={1} />);
// 等待加载状态消失
await waitForElementToBeRemoved(() =>
screen.queryByTestId('loading-spinner')
);
// 验证用户信息显示
await waitFor(() => {
expect(screen.getByTestId('user-name')).toHaveTextContent('张三');
expect(screen.getByTestId('user-email')).toHaveTextContent(
'邮箱: zhangsan@example.com'
);
expect(screen.getByTestId('user-role')).toHaveTextContent(
'角色: 管理员'
);
});
// 验证API调用
expect(axios.get).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledWith('/api/users/1');
});
test('处理API错误', async () => {
axios.get.mockRejectedValue(new Error('网络错误'));
render(<UserProfile userId={1} />);
await waitForElementToBeRemoved(() =>
screen.queryByTestId('loading-spinner')
);
await waitFor(() => {
expect(screen.getByTestId('error-message')).toBeInTheDocument();
expect(screen.getByTestId('error-message')).toHaveTextContent(
'获取用户信息失败'
);
});
});
test('组件卸载时取消请求', async () => {
const cancelToken = {
cancel: jest.fn()
};
axios.CancelToken = {
source: jest.fn(() => ({
token: 'mock-token',
cancel: cancelToken.cancel
}))
};
axios.get.mockResolvedValue({ data: mockUser });
const { unmount } = render(<UserProfile userId={1} />);
// 立即卸载组件
unmount();
// 验证取消函数被调用
expect(cancelToken.cancel).toHaveBeenCalledWith(
'组件卸载,取消请求'
);
});
});
// 使用MSW进行API Mocking
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params;
if (id === '1') {
return res(
ctx.json({
id: 1,
name: '李四',
email: 'lisi@example.com'
})
);
}
return res(
ctx.status(404),
ctx.json({ message: '用户不存在' })
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('使用MSW测试API调用', async () => {
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByTestId('user-name')).toHaveTextContent('李四');
});
});
// ErrorBoundary.jsx
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// 可以在这里记录错误到日志服务
console.error('ErrorBoundary捕获错误:', error, errorInfo);
}
resetError = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div data-testid="error-boundary">
<h3>组件发生错误</h3>
<p data-testid="error-message">
{this.state.error.toString()}
</p>
<button
data-testid="reset-button"
onClick={this.resetError}
>
重试
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
// 有问题的组件
const BuggyComponent = ({ shouldThrow = false }) => {
if (shouldThrow) {
throw new Error('测试错误');
}
return <div data-testid="safe-content">正常内容</div>;
};
// ErrorBoundary.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ErrorBoundary from './ErrorBoundary';
import BuggyComponent from './BuggyComponent';
// 抑制控制台错误输出
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
console.error.mockRestore();
});
describe('ErrorBoundary组件', () => {
test('正常渲染子组件', () => {
render(
<ErrorBoundary>
<div data-testid="child">子组件</div>
</ErrorBoundary>
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.queryByTestId('error-boundary')).not.toBeInTheDocument();
});
test('捕获子组件错误并显示降级UI', () => {
render(
<ErrorBoundary>
<BuggyComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByTestId('error-boundary')).toBeInTheDocument();
expect(screen.getByTestId('error-message')).toHaveTextContent(
'Error: 测试错误'
);
expect(screen.getByTestId('reset-button')).toBeInTheDocument();
});
test('点击重试按钮重置错误状态', () => {
const { rerender } = render(
<ErrorBoundary>
<BuggyComponent shouldThrow={true} />
</ErrorBoundary>
);
// 确认错误显示
expect(screen.getByTestId('error-boundary')).toBeInTheDocument();
// 点击重试按钮
fireEvent.click(screen.getByTestId('reset-button'));
// 重新渲染不抛出错误的组件
rerender(
<ErrorBoundary>
<BuggyComponent shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.queryByTestId('error-boundary')).not.toBeInTheDocument();
expect(screen.getByTestId('safe-content')).toBeInTheDocument();
});
test('组件树深层的错误被捕获', () => {
const DeepBuggyComponent = () => (
<div>
<div>外层</div>
<BuggyComponent shouldThrow={true} />
</div>
);
render(
<ErrorBoundary>
<DeepBuggyComponent />
</ErrorBoundary>
);
expect(screen.getByTestId('error-boundary')).toBeInTheDocument();
});
});
// cypress/integration/login.spec.js
describe('登录流程', () => {
beforeEach(() => {
// 每次测试前访问登录页
cy.visit('/login');
});
it('成功登录', () => {
// 输入用户名和密码
cy.get('[data-testid=email-input]').type('user@example.com');
cy.get('[data-testid=password-input]').type('password123');
// 点击登录按钮
cy.get('[data-testid=submit-button]').click();
// 验证跳转到首页
cy.url().should('include', '/dashboard');
// 验证用户信息显示
cy.get('[data-testid=welcome-message]').should(
'contain', '欢迎 user@example.com'
);
});
it('显示验证错误', () => {
// 不输入密码直接提交
cy.get('[data-testid=email-input]').type('user@example.com');
cy.get('[data-testid=submit-button]').click();
// 验证错误信息显示
cy.get('[data-testid=password-error]').should(
'contain', '密码不能为空'
);
// 验证没有跳转
cy.url().should('include', '/login');
});
it('记住登录状态', () => {
// 登录并勾选"记住我"
cy.get('[data-testid=email-input]').type('user@example.com');
cy.get('[data-testid=password-input]').type('password123');
cy.get('[data-testid=remember-me]').check();
cy.get('[data-testid=submit-button]').click();
// 刷新页面
cy.reload();
// 验证仍然保持登录状态
cy.url().should('include', '/dashboard');
});
it('网络错误处理', () => {
// 模拟网络错误
cy.intercept('POST', '/api/login', {
statusCode: 500,
body: { error: '服务器错误' }
});
cy.get('[data-testid=email-input]').type('user@example.com');
cy.get('[data-testid=password-input]').type('password123');
cy.get('[data-testid=submit-button]').click();
// 验证错误提示
cy.get('[data-testid=network-error]').should(
'contain', '登录失败,请重试'
);
});
});
// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('[data-testid=email-input]').type(email);
cy.get('[data-testid=password-input]').type(password);
cy.get('[data-testid=submit-button]').click();
cy.url().should('include', '/dashboard');
});
Cypress.Commands.add('logout', () => {
cy.get('[data-testid=logout-button]').click();
cy.url().should('include', '/login');
});
// 使用自定义命令
describe('使用自定义命令', () => {
it('登录后执行操作', () => {
cy.login('user@example.com', 'password123');
// 登录后的操作
cy.get('[data-testid=dashboard-title]').should('be.visible');
cy.logout();
cy.url().should('include', '/login');
});
});
95%
目标: > 90%85%
目标: > 80%45s
目标: < 60s247
持续增加