前端测试的重要性与基础概念
在现代前端开发中,测试已经成为保证代码质量和开发效率的关键环节。随着前端应用复杂度的不断提升,仅仅依靠手动测试已经无法满足快速迭代的需求。前端测试不仅能够帮助我们发现潜在的bug,还能为代码重构提供安全保障,让开发团队更加自信地进行代码改进。
前端测试主要分为三个层次:单元测试、集成测试和端到端(E2E)测试。这三种测试类型各有侧重,相互补充,构成了完整的测试金字塔。单元测试关注单个函数或组件的行为,集成测试验证多个模块之间的协作,而端到端测试则模拟真实用户的操作流程。
为什么前端测试如此重要?
- 提高代码质量:通过测试可以及早发现bug,避免问题流入生产环境
- 支持重构:完善的测试套件让代码重构变得更加安全
- 文档作用:测试用例本身就是最好的使用文档
- 提升开发效率:虽然初期会增加开发时间,但长期来看能大幅减少调试时间
- 团队协作:清晰的测试用例帮助团队成员理解代码预期行为
单元测试:从零开始
单元测试基础
单元测试是前端测试金字塔的基石,它专注于测试最小的可测试单元——通常是函数、方法或组件。在JavaScript生态中,Jest是最受欢迎的单元测试框架之一。
Jest基础配置
首先,让我们创建一个简单的项目并配置Jest:
# 初始化项目
mkdir frontend-testing-course
cd frontend-testing-course
npm init -y
# 安装Jest
npm install --save-dev jest
# 修改package.json添加测试脚本
{
"name": "frontend-testing-course",
"version": "1.0.0",
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
},
"devDependencies": {
"jest": "^29.0.0"
}
}
编写第一个单元测试
让我们从一个简单的数学函数开始:
// math.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}
module.exports = { add, multiply, divide };
对应的测试文件:
// math.test.js
const { add, multiply, divide } = require('./math');
describe('数学运算函数测试', () => {
describe('add函数', () => {
test('应该正确计算两个正数的和', () => {
expect(add(2, 3)).toBe(5);
});
test('应该正确处理负数', () => {
expect(add(-1, -2)).toBe(-3);
});
test('应该正确处理零', () => {
expect(add(0, 5)).toBe(5);
});
});
describe('multiply函数', () => {
test('应该正确计算乘积', () => {
expect(multiply(3, 4)).toBe(12);
});
test('应该正确处理零', () => {
expect(multiply(0, 100)).toBe(0);
});
});
describe('divide函数', () => {
test('应该正确计算除法', () => {
expect(divide(10, 2)).toBe(5);
});
test('应该抛出错误当除数为零', () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
});
});
React组件单元测试
对于React组件,我们需要额外的工具来渲染组件并进行断言。这里使用React Testing Library:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest-environment-jsdom
创建一个简单的React组件:
// Button.jsx
import React from 'react';
const Button = ({ onClick, children, disabled = false, variant = 'primary' }) => {
const baseClasses = 'px-4 py-2 rounded font-medium transition-colors';
const variantClasses = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-red-500 text-white hover:bg-red-600'
};
return (
<button
onClick={onClick}
disabled={disabled}
className={`${baseClasses} ${variantClasses[variant]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{children}
</button>
);
};
export default Button;
对应的测试文件:
// Button.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Button from './Button';
describe('Button组件测试', () => {
test('应该正确渲染按钮文本', () => {
render(<Button>点击我</Button>);
expect(screen.getByText('点击我')).toBeInTheDocument();
});
test('应该触发onClick事件', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>测试按钮</Button>);
fireEvent.click(screen.getByText('测试按钮'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('应该禁用按钮当disabled为true', () => {
render(<Button disabled={true}>禁用按钮</Button>);
const button = screen.getByText('禁用按钮');
expect(button).toBeDisabled();
});
test('应该应用正确的样式变体', () => {
const { rerender } = render(<Button variant="primary">主要按钮</Button>);
const button = screen.getByText('主要按钮');
expect(button).toHaveClass('bg-blue-500');
rerender(<Button variant="danger">危险按钮</Button>);
expect(screen.getByText('危险按钮')).toHaveClass('bg-red-500');
});
});
高级单元测试技巧
1. Mock函数和模拟
// api.js
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
// api.test.js
describe('fetchUserData', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
test('应该成功获取用户数据', async () => {
const mockUser = { id: 1, name: 'John Doe' };
global.fetch.mockResolvedValue({
ok: true,
json: async () => mockUser
});
const result = await fetchUserData(1);
expect(result).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
});
test('应该处理API错误', async () => {
global.fetch.mockResolvedValue({
ok: false,
status: 404
});
await expect(fetchUserData(1)).rejects.toThrow('Failed to fetch user');
});
});
2. 异步测试
// asyncUtils.js
function delayResolve(value, delay) {
return new Promise(resolve => setTimeout(() => resolve(value), delay));
}
function delayReject(error, delay) {
return new Promise((_, reject) => setTimeout(() => reject(error), delay));
}
// asyncUtils.test.js
describe('异步工具函数', () => {
test('delayResolve应该在延迟后解析', async () => {
const start = Date.now();
const result = await delayResolve('success', 100);
const end = Date.now();
expect(result).toBe('success');
expect(end - start).toBeGreaterThanOrEqual(100);
});
test('delayReject应该在延迟后拒绝', async () => {
await expect(delayReject(new Error('failed'), 100)).rejects.toThrow('failed');
});
});
集成测试:验证模块协作
集成测试基础
集成测试关注多个模块如何协同工作。在前端中,这通常涉及组件之间的交互、API调用、状态管理等。
React组件集成测试示例
让我们创建一个包含表单和API调用的复杂场景:
// UserForm.jsx
import React, { useState } from 'react';
const UserForm = ({ onSubmit }) => {
const [formData, setFormData] = useState({ name: '', email: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await onSubmit(formData);
setFormData({ name: '', email: '' });
} catch (err) {
setError(err.message);
} finally {
setIsSubmitting(false);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
return (
<form onSubmit={handleSubmit} data-testid="user-form">
<div>
<label htmlFor="name">姓名:</label>
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
disabled={isSubmitting}
data-testid="name-input"
/>
</div>
<div>
<label htmlFor="email">邮箱:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
disabled={isSubmitting}
data-testid="email-input"
/>
</div>
{error && <div data-testid="error-message">{error}</div>}
<button
type="submit"
disabled={isSubmitting}
data-testid="submit-button"
>
{isSubmitting ? '提交中...' : '提交'}
</button>
</form>
);
};
export default UserForm;
集成测试:
// UserForm.test.jsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserForm from './UserForm';
describe('UserForm集成测试', () => {
test('应该完整处理表单提交流程', async () => {
const mockSubmit = jest.fn().mockResolvedValue({ success: true });
render(<UserForm onSubmit={mockSubmit} />);
// 填写表单
const nameInput = screen.getByTestId('name-input');
const emailInput = screen.getByTestId('email-input');
const submitButton = screen.getByTestId('submit-button');
fireEvent.change(nameInput, { target: { name: 'name', value: '张三' } });
fireEvent.change(emailInput, { target: { name: 'email', value: 'zhangsan@example.com' } });
// 验证输入值
expect(nameInput.value).toBe('张三');
expect(emailInput.value).toBe('zhangsan@example.com');
// 提交表单
fireEvent.click(submitButton);
// 验证提交状态
expect(submitButton).toBeDisabled();
expect(submitButton.textContent).toBe('提交中...');
// 等待提交完成
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
name: '张三',
email: 'zhangsan@example.com'
});
});
// 验证表单重置
expect(nameInput.value).toBe('');
expect(emailInput.value).toBe('');
expect(submitButton).not.toBeDisabled();
});
test('应该处理提交错误', async () => {
const mockSubmit = jest.fn().mockRejectedValue(new Error('服务器错误'));
render(<UserForm onSubmit={mockSubmit} />);
const nameInput = screen.getByTestId('name-input');
const emailInput = screen.getByTestId('email-input');
const submitButton = screen.getByTestId('submit-button');
fireEvent.change(nameInput, { target: { name: 'name', value: '李四' } });
fireEvent.change(emailInput, { target: { name: 'email', value: 'lisi@example.com' } });
fireEvent.click(submitButton);
await waitFor(() => {
const errorElement = screen.getByTestId('error-message');
expect(errorElement).toHaveTextContent('服务器错误');
});
// 验证表单仍然保持错误状态
expect(nameInput.value).toBe('李四');
expect(submitButton).not.toBeDisabled();
});
});
状态管理集成测试
对于使用Redux或Context API的应用,集成测试尤为重要:
// store.js
import { createStore } from 'redux';
function todoReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now(), text: action.payload, completed: false }];
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
);
case 'DELETE_TODO':
return state.filter(todo => todo.id !== action.payload);
default:
return state;
}
}
const store = createStore(todoReducer);
export default store;
// TodoApp.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
const TodoApp = () => {
const todos = useSelector(state => state);
const dispatch = useDispatch();
const [inputValue, setInputValue] = React.useState('');
const handleAdd = () => {
if (inputValue.trim()) {
dispatch({ type: 'ADD_TODO', payload: inputValue });
setInputValue('');
}
};
return (
<div>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
data-testid="todo-input"
/>
<button onClick={handleAdd} data-testid="add-button">添加</button>
<ul data-testid="todo-list">
{todos.map(todo => (
<li key={todo.id} data-testid={`todo-${todo.id}`}>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}>
{todo.completed ? '取消' : '完成'}
</button>
<button onClick={() => dispatch({ type: 'DELETE_TODO', payload: todo.id })}>
删除
</button>
</li>
))}
</ul>
</div>
);
};
export default TodoApp;
// TodoApp.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';
import '@testing-library/jest-dom';
import TodoApp, { todoReducer } from './TodoApp';
// 创建测试专用的store
const renderWithRedux = (
component,
{ initialState, store = createStore(todoReducer, initialState) } = {}
) => {
return {
...render(<Provider store={store}>{component}</Provider>),
store
};
};
describe('TodoApp集成测试', () => {
test('应该添加新todo', () => {
renderWithRedux(<TodoApp />);
const input = screen.getByTestId('todo-input');
const button = screen.getByTestId('add-button');
fireEvent.change(input, { target: { value: '学习测试' } });
fireEvent.click(button);
expect(screen.getByTestId('todo-list')).toHaveTextContent('学习测试');
expect(input.value).toBe('');
});
test('应该切换todo完成状态', () => {
const initialState = [{ id: 1, text: '测试任务', completed: false }];
renderWithRedux(<TodoApp />, { initialState });
const toggleButton = screen.getByText('完成');
fireEvent.click(toggleButton);
const todoItem = screen.getByTestId('todo-1');
expect(todoItem.querySelector('span')).toHaveStyle('text-decoration: line-through');
expect(screen.getByText('取消')).toBeInTheDocument();
});
test('应该删除todo', () => {
const initialState = [
{ id: 1, text: '任务1', completed: false },
{ id: 2, text: '任务2', completed: false }
];
renderWithRedux(<TodoApp />, { initialState });
expect(screen.getByTestId('todo-1')).toBeInTheDocument();
expect(screen.getByTestId('todo-2')).toBeInTheDocument();
const deleteButtons = screen.getAllByText('删除');
fireEvent.click(deleteButtons[0]);
expect(screen.queryByTestId('todo-1')).not.toBeInTheDocument();
expect(screen.getByTestId('todo-2')).toBeInTheDocument();
});
});
端到端(E2E)测试:模拟真实用户场景
E2E测试基础
端到端测试模拟真实用户的行为,从浏览器层面验证整个应用的流程。Cypress是目前最流行的前端E2E测试框架。
Cypress安装和配置
npm install --save-dev cypress
在package.json中添加脚本:
{
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run"
}
}
创建Cypress配置文件:
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
// 实现插件事件
},
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/e2e.js'
},
component: {
devServer: {
framework: 'react',
bundler: 'webpack'
}
}
});
编写E2E测试用例
让我们为一个电商网站的购物流程编写E2E测试:
// cypress/e2e/shopping-flow.cy.js
describe('电商购物流程测试', () => {
beforeEach(() => {
cy.visit('/');
});
it('应该完成完整的购买流程', () => {
// 1. 登录
cy.get('[data-testid="login-button"]').click();
cy.get('[data-testid="username-input"]').type('testuser');
cy.get('[data-testid="password-input"]').type('password123');
cy.get('[data-testid="submit-login"]').click();
// 验证登录成功
cy.url().should('include', '/dashboard');
cy.get('[data-testid="user-welcome"]').should('contain', '欢迎');
// 2. 搜索商品
cy.get('[data-testid="search-input"]').type('iPhone 15');
cy.get('[data-testid="search-button"]').click();
// 3. 选择商品
cy.get('[data-testid="product-item"]').first().click();
// 4. 添加到购物车
cy.get('[data-testid="add-to-cart"]').click();
// 验证购物车数量
cy.get('[data-testid="cart-count"]').should('contain', '1');
// 5. 进入购物车
cy.get('[data-testid="cart-icon"]').click();
cy.url().should('include', '/cart');
// 6. 结账
cy.get('[data-testid="checkout-button"]').click();
// 7. 填写配送信息
cy.get('[data-testid="shipping-name"]').type('张三');
cy.get('[data-testid="shipping-address"]').type('北京市朝阳区');
cy.get('[data-testid="shipping-phone"]').type('13800138000');
// 8. 选择支付方式
cy.get('[data-testid="payment-method"]').select('信用卡');
cy.get('[data-testid="card-number"]').type('4111111111111111');
cy.get('[data-testid="card-expiry"]').type('12/25');
cy.get('[data-testid="card-cvv"]').type('123');
// 9. 提交订单
cy.get('[data-testid="place-order"]').click();
// 10. 验证订单成功
cy.url().should('include', '/order-confirmation');
cy.get('[data-testid="order-success"]').should('be.visible');
cy.get('[data-testid="order-number"]').should('exist');
});
it('应该处理库存不足的情况', () => {
// 模拟库存不足的商品
cy.intercept('GET', '/api/products/low-stock', {
statusCode: 200,
body: {
id: 999,
name: '限量商品',
price: 999,
stock: 1
}
});
cy.visit('/product/low-stock');
// 快速添加多个商品到购物车
cy.get('[data-testid="add-to-cart"]').click().click().click();
// 验证错误提示
cy.get('[data-testid="error-message"]').should('contain', '库存不足');
});
it('应该验证表单验证', () => {
cy.visit('/register');
// 尝试提交空表单
cy.get('[data-testid="submit-button"]').click();
// 验证错误提示
cy.get('[data-testid="email-error"]').should('contain', '邮箱不能为空');
cy.get('[data-testid="password-error"]').should('contain', '密码不能为空');
// 填写无效数据
cy.get('[data-testid="email-input"]').type('invalid-email');
cy.get('[data-testid="password-input"]').type('123');
cy.get('[data-testid="submit-button"]').click();
cy.get('[data-testid="email-error"]').should('contain', '邮箱格式不正确');
cy.get('[data-testid="password-error"]').should('contain', '密码至少需要6个字符');
});
});
高级E2E测试技巧
1. 网络请求拦截
// cypress/e2e/api-testing.cy.js
describe('API集成测试', () => {
it('应该正确处理API响应', () => {
// 拦截API请求并返回模拟数据
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
]
}).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers').then((interception) => {
expect(interception.response.statusCode).to.equal(200);
expect(interception.response.body).to.have.length(2);
});
cy.get('[data-testid="user-list"] li').should('have.length', 2);
});
it('应该处理API错误', () => {
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: { error: 'Invalid credentials' }
});
cy.visit('/login');
cy.get('[data-testid="username"]').type('wronguser');
cy.get('[data-testid="password"]').type('wrongpass');
cy.get('[data-testid="submit"]').click();
cy.get('[data-testid="error-message"]').should('contain', 'Invalid credentials');
});
});
2. 性能测试
// cypress/e2e/performance.cy.js
describe('性能测试', () => {
it('应该在2秒内加载首页', () => {
cy.visit('/', {
onBeforeLoad: (win) => {
win.performance.mark('page-load-start');
}
});
cy.get('[data-testid="main-content"]').should('be.visible').then(() => {
cy.window().then((win) => {
win.performance.mark('page-load-end');
win.performance.measure('page-load', 'page-load-start', 'page-load-end');
const measure = win.performance.getEntriesByName('page-load')[0];
expect(measure.duration).to.be.lessThan(2000);
});
});
});
it('应该验证关键资源加载', () => {
const resources = [];
cy.intercept('*', (req) => {
req.continue((res) => {
resources.push({
url: req.url,
size: res.headers['content-length'],
duration: res.duration
});
});
});
cy.visit('/dashboard');
cy.then(() => {
const mainBundle = resources.find(r => r.url.includes('main.js'));
expect(mainBundle).to.exist;
expect(mainBundle.duration).to.be.lessThan(1000);
});
});
});
测试最佳实践和策略
测试金字塔原则
/ \
/ E2E \ <- 少量端到端测试
/_______\
/ API \ <- 中等数量集成测试
/___________\
/ Unit Tests \ <- 大量单元测试
/_______________\
测试命名规范
好的测试名称应该清晰描述测试意图:
// 不好的命名
test('test1', () => { /* ... */ });
test('add function', () => { /* ... */ });
// 好的命名
test('当输入为负数时,add函数应该返回正确的和', () => { /* ... */ });
test('应该在用户点击提交按钮时显示加载状态', () => { /* ... */ });
测试数据管理
// 测试数据工厂
const createTestUser = (overrides = {}) => ({
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
});
// 在测试中使用
test('应该显示管理员标签', () => {
const adminUser = createTestUser({ role: 'admin' });
render(<UserProfile user={adminUser} />);
expect(screen.getByText('管理员')).toBeInTheDocument();
});
测试隔离原则
// 每个测试应该独立运行
describe('用户管理', () => {
// 使用beforeEach确保每个测试都有干净的环境
beforeEach(() => {
// 重置所有状态
cleanup();
// 重置mock
jest.clearAllMocks();
});
test('测试1', () => { /* ... */ });
test('测试2', () => { /* ... */ });
});
持续集成与自动化
GitHub Actions配置
# .github/workflows/test.yml
name: Frontend Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --coverage
- name: Run E2E tests
uses: cypress-io/github-action@v5
with:
start: npm start
wait-on: 'http://localhost:3000'
record: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
测试覆盖率报告
// jest.config.js
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.test.{js,jsx,ts,tsx}',
'!src/index.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
coverageReporters: ['text', 'lcov', 'html']
};
实战项目:构建完整的测试套件
让我们通过一个完整的项目来实践所有学到的知识。
项目结构
frontend-testing-project/
├── src/
│ ├── components/
│ │ ├── Button.jsx
│ │ ├── Form.jsx
│ │ └── ProductList.jsx
│ ├── hooks/
│ │ ├── useAuth.js
│ │ └── useCart.js
│ ├── services/
│ │ ├── api.js
│ │ └── storage.js
│ └── App.jsx
├── tests/
│ ├── unit/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── services/
│ ├── integration/
│ │ └── flows/
│ └── e2e/
│ └── cypress/
├── cypress.config.js
└── jest.config.js
完整的测试示例
// src/services/api.js
class API {
constructor(baseURL) {
this.baseURL = baseURL;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'API Error');
}
return response.json();
}
async getProducts() {
return this.request('/products');
}
async createOrder(orderData) {
return this.request('/orders', {
method: 'POST',
body: JSON.stringify(orderData)
});
}
}
// tests/unit/services/api.test.js
const { API } = require('../../src/services/api');
describe('API Service', () => {
let api;
let mockFetch;
beforeEach(() => {
api = new API('https://api.example.com');
mockFetch = jest.fn();
global.fetch = mockFetch;
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('request method', () => {
it('应该成功处理GET请求', async () => {
const mockData = { success: true };
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockData
});
const result = await api.request('/test');
expect(result).toEqual(mockData);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', {
headers: { 'Content-Type': 'application/json' }
});
});
it('应该处理API错误', async () => {
mockFetch.mockResolvedValue({
ok: false,
json: async () => ({ message: 'Not found' })
});
await expect(api.request('/not-found')).rejects.toThrow('Not found');
});
});
describe('getProducts', () => {
it('应该获取产品列表', async () => {
const mockProducts = [{ id: 1, name: 'Product 1' }];
mockFetch.mockResolvedValue({
ok: true,
json: async () => mockProducts
});
const result = await api.getProducts();
expect(result).toEqual(mockProducts);
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/products', {
headers: { 'Content-Type': 'application/json' }
});
});
});
});
集成测试示例
// src/hooks/useCart.js
import { useState, useEffect } from 'react';
import { API } from '../services/api';
export const useCart = () => {
const [cart, setCart] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const api = new API('https://api.example.com');
const addToCart = async (product) => {
setLoading(true);
setError(null);
try {
const updatedCart = await api.createOrder({ ...product, quantity: 1 });
setCart(updatedCart);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const removeFromCart = (productId) => {
setCart(prev => prev.filter(item => item.id !== productId));
};
const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
return { cart, loading, error, addToCart, removeFromCart, total };
};
// tests/integration/hooks/useCart.test.js
import { renderHook, act } from '@testing-library/react';
import { useCart } from '../../../src/hooks/useCart';
import { API } from '../../../src/services/api';
jest.mock('../../../src/services/api');
describe('useCart Hook Integration', () => {
let mockApi;
beforeEach(() => {
mockApi = {
createOrder: jest.fn()
};
API.mockImplementation(() => mockApi);
});
test('应该成功添加商品到购物车', async () => {
const mockProduct = { id: 1, name: 'Product', price: 100 };
const mockCart = [{ id: 1, name: 'Product', price: 100, quantity: 1 }];
mockApi.createOrder.mockResolvedValue(mockCart);
const { result } = renderHook(() => useCart());
await act(async () => {
await result.current.addToCart(mockProduct);
});
expect(result.current.cart).toEqual(mockCart);
expect(result.current.total).toBe(100);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
test('应该处理添加商品失败', async () => {
mockApi.createOrder.mockRejectedValue(new Error('库存不足'));
const { result } = renderHook(() => useCart());
await act(async () => {
await result.current.addToCart({ id: 1 });
});
expect(result.current.error).toBe('库存不足');
expect(result.current.loading).toBe(false);
expect(result.current.cart).toEqual([]);
});
});
测试工具和生态系统
测试覆盖率工具
// 生成详细的覆盖率报告
// package.json scripts
{
"scripts": {
"test:coverage": "jest --coverage",
"test:watch": "jest --watch",
"test:ci": "jest --ci --coverage --maxWorkers=2",
"cypress:run": "cypress run",
"cypress:open": "cypress open",
"test:e2e": "start-server-and-test start http://localhost:3000 cypress:run"
}
}
测试监控和报告
// jest.config.js with advanced settings
module.exports = {
verbose: true,
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.test.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/reportWebVitals.js'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
'./src/components/**/*.js': {
branches: 90,
functions: 90
}
},
coverageReporters: [
'text',
'lcov',
'html',
'json-summary'
],
testMatch: [
'**/tests/**/*.test.js',
'**/?(*.)+(spec|test).js'
],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest'
},
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js'
}
};
总结与进阶建议
通过本课程的学习,我们从零基础开始,逐步掌握了前端测试的三个核心层次:
- 单元测试:关注最小可测试单元,使用Jest进行函数和组件的测试
- 集成测试:验证模块间的协作,测试组件组合和状态管理
- 端到端测试:模拟真实用户场景,使用Cypress进行完整流程验证
持续改进的建议
- 逐步实施:从新功能开始写测试,逐步覆盖旧代码
- 测试驱动开发(TDD):先写测试,再写实现代码
- 代码审查:将测试用例作为代码审查的一部分
- 监控指标:关注测试覆盖率、测试执行时间、失败率等指标
- 定期重构:测试代码也需要维护和重构
学习资源推荐
- Jest官方文档:https://jestjs.io/
- React Testing Library:https://testing-library.com/docs/react-testing-library/intro/
- Cypress官方文档:https://docs.cypress.io/
- Testing Library用户指南:https://testing-library.com/docs/guiding-principles/
记住,好的测试不是为了100%的覆盖率,而是为了提供信心和安全保障。测试应该是开发的助力,而不是阻力。通过合理的测试策略和工具选择,我们可以显著提升代码质量和开发效率。
