前端自动化测试实战
前端自动化测试是保证代码质量、提高开发效率的重要手段。本文将从测试策略到具体实现,全面介绍前端测试的最佳实践。
测试金字塔
前端测试通常遵循测试金字塔原则:
/\
/ \ E2E Tests (少量)
/____\
/ \
/________\ Integration Tests (适量)
/ \
/____________\ Unit Tests (大量)
1. 单元测试 (Unit Tests)
- 目标: 测试独立的函数、组件
- 特点: 快速、稳定、易维护
- 占比: 70%
2. 集成测试 (Integration Tests)
- 目标: 测试组件间的交互
- 特点: 中等速度、中等复杂度
- 占比: 20%
3. 端到端测试 (E2E Tests)
- 目标: 测试完整的用户流程
- 特点: 慢速、复杂、但最接近真实场景
- 占比: 10%
测试工具生态
测试框架对比
框架 | 优势 | 劣势 | 适用场景 |
---|---|---|---|
Jest | 零配置、快照测试、内置断言 | 主要针对 Node.js | React、Vue、通用 JS |
Vitest | 极快速度、与 Vite 集成 | 相对较新 | Vite 项目 |
Mocha | 灵活、可配置性强 | 需要额外配置 | 复杂测试需求 |
Jasmine | 简单易用、BDD 风格 | 功能相对基础 | 简单项目 |
E2E 测试工具对比
工具 | 优势 | 劣势 | 适用场景 |
---|---|---|---|
Cypress | 开发体验好、调试方便 | 只支持 Chrome 系 | 现代 Web 应用 |
Playwright | 跨浏览器、性能好 | 学习曲线陡峭 | 企业级应用 |
Puppeteer | Google 官方、功能强大 | 只支持 Chrome | Chrome 专用场景 |
单元测试实战
1. Jest 基础配置
javascript
// jest.config.js
module.exports = {
// 测试环境
testEnvironment: 'jsdom',
// 文件匹配模式
testMatch: [
'**/__tests__/**/*.(js|jsx|ts|tsx)',
'**/*.(test|spec).(js|jsx|ts|tsx)'
],
// 模块路径映射
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
// 设置文件
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// 覆盖率配置
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js'
],
// 转换配置
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest'
}
};
2. 工具函数测试
javascript
// utils/math.js
export function add(a, b) {
return a + b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
export function formatCurrency(amount) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(amount);
}
javascript
// utils/__tests__/math.test.js
import { add, divide, formatCurrency } from '../math';
describe('Math utilities', () => {
describe('add', () => {
test('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('should handle negative numbers', () => {
expect(add(-1, 1)).toBe(0);
expect(add(-2, -3)).toBe(-5);
});
test('should handle decimal numbers', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3);
});
});
describe('divide', () => {
test('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
test('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
test('should handle decimal results', () => {
expect(divide(1, 3)).toBeCloseTo(0.333333);
});
});
describe('formatCurrency', () => {
test('should format positive amount', () => {
expect(formatCurrency(1234.56)).toBe('¥1,234.56');
});
test('should format zero', () => {
expect(formatCurrency(0)).toBe('¥0.00');
});
test('should format negative amount', () => {
expect(formatCurrency(-100)).toBe('-¥100.00');
});
});
});
3. React 组件测试
jsx
// components/Button.jsx
import React from 'react';
import PropTypes from 'prop-types';
const Button = ({
children,
onClick,
disabled = false,
variant = 'primary',
size = 'medium'
}) => {
const className = `btn btn-${variant} btn-${size}`;
return (
<button
className={className}
onClick={onClick}
disabled={disabled}
data-testid="button"
>
{children}
</button>
);
};
Button.propTypes = {
children: PropTypes.node.isRequired,
onClick: PropTypes.func,
disabled: PropTypes.bool,
variant: PropTypes.oneOf(['primary', 'secondary', 'danger']),
size: PropTypes.oneOf(['small', 'medium', 'large'])
};
export default Button;
jsx
// components/__tests__/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 Component', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toHaveTextContent('Click me');
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
test('applies correct CSS classes', () => {
render(
<Button variant="danger" size="large">
Delete
</Button>
);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn', 'btn-danger', 'btn-large');
});
test('does not call onClick when disabled', () => {
const handleClick = jest.fn();
render(
<Button onClick={handleClick} disabled>
Click me
</Button>
);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
4. Vue 组件测试
vue
<!-- components/UserCard.vue -->
<template>
<div class="user-card" data-testid="user-card">
<img
:src="user.avatar"
:alt="`${user.name} avatar`"
class="avatar"
/>
<div class="user-info">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button
@click="$emit('follow', user.id)"
:disabled="isFollowing"
class="follow-btn"
>
{{ isFollowing ? 'Following' : 'Follow' }}
</button>
</div>
</div>
</template>
<script>
export default {
name: 'UserCard',
props: {
user: {
type: Object,
required: true
},
isFollowing: {
type: Boolean,
default: false
}
},
emits: ['follow']
};
</script>
javascript
// components/__tests__/UserCard.test.js
import { mount } from '@vue/test-utils';
import UserCard from '../UserCard.vue';
describe('UserCard', () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
avatar: 'https://example.com/avatar.jpg'
};
test('renders user information', () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
});
expect(wrapper.text()).toContain('John Doe');
expect(wrapper.text()).toContain('john@example.com');
expect(wrapper.find('img').attributes('src')).toBe(mockUser.avatar);
});
test('emits follow event when button clicked', async () => {
const wrapper = mount(UserCard, {
props: { user: mockUser }
});
await wrapper.find('.follow-btn').trigger('click');
expect(wrapper.emitted('follow')).toBeTruthy();
expect(wrapper.emitted('follow')[0]).toEqual([mockUser.id]);
});
test('shows correct button text based on following status', () => {
const wrapper = mount(UserCard, {
props: {
user: mockUser,
isFollowing: true
}
});
expect(wrapper.find('.follow-btn').text()).toBe('Following');
expect(wrapper.find('.follow-btn').attributes('disabled')).toBeDefined();
});
});
集成测试实战
1. API 集成测试
javascript
// services/userService.js
class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async getUsers(page = 1, limit = 10) {
const response = await this.apiClient.get('/users', {
params: { page, limit }
});
return response.data;
}
async createUser(userData) {
const response = await this.apiClient.post('/users', userData);
return response.data;
}
async updateUser(id, userData) {
const response = await this.apiClient.put(`/users/${id}`, userData);
return response.data;
}
}
export default UserService;
javascript
// services/__tests__/userService.test.js
import UserService from '../userService';
// Mock API client
const mockApiClient = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn()
};
describe('UserService', () => {
let userService;
beforeEach(() => {
userService = new UserService(mockApiClient);
jest.clearAllMocks();
});
describe('getUsers', () => {
test('should fetch users with default pagination', async () => {
const mockResponse = {
data: {
users: [{ id: 1, name: 'John' }],
total: 1
}
};
mockApiClient.get.mockResolvedValue(mockResponse);
const result = await userService.getUsers();
expect(mockApiClient.get).toHaveBeenCalledWith('/users', {
params: { page: 1, limit: 10 }
});
expect(result).toEqual(mockResponse.data);
});
test('should handle API errors', async () => {
const error = new Error('Network error');
mockApiClient.get.mockRejectedValue(error);
await expect(userService.getUsers()).rejects.toThrow('Network error');
});
});
describe('createUser', () => {
test('should create user successfully', async () => {
const userData = { name: 'Jane', email: 'jane@example.com' };
const mockResponse = { data: { id: 2, ...userData } };
mockApiClient.post.mockResolvedValue(mockResponse);
const result = await userService.createUser(userData);
expect(mockApiClient.post).toHaveBeenCalledWith('/users', userData);
expect(result).toEqual(mockResponse.data);
});
});
});
2. 状态管理测试
javascript
// store/userStore.js (Vuex)
export const state = () => ({
users: [],
loading: false,
error: null
});
export const mutations = {
SET_LOADING(state, loading) {
state.loading = loading;
},
SET_USERS(state, users) {
state.users = users;
},
SET_ERROR(state, error) {
state.error = error;
},
ADD_USER(state, user) {
state.users.push(user);
}
};
export const actions = {
async fetchUsers({ commit }, { page = 1, limit = 10 } = {}) {
commit('SET_LOADING', true);
commit('SET_ERROR', null);
try {
const response = await this.$api.get('/users', {
params: { page, limit }
});
commit('SET_USERS', response.data.users);
} catch (error) {
commit('SET_ERROR', error.message);
} finally {
commit('SET_LOADING', false);
}
}
};
javascript
// store/__tests__/userStore.test.js
import { mutations, actions } from '../userStore';
describe('User Store', () => {
describe('mutations', () => {
test('SET_LOADING should update loading state', () => {
const state = { loading: false };
mutations.SET_LOADING(state, true);
expect(state.loading).toBe(true);
});
test('SET_USERS should update users array', () => {
const state = { users: [] };
const users = [{ id: 1, name: 'John' }];
mutations.SET_USERS(state, users);
expect(state.users).toEqual(users);
});
test('ADD_USER should add user to array', () => {
const state = { users: [] };
const user = { id: 1, name: 'John' };
mutations.ADD_USER(state, user);
expect(state.users).toContain(user);
});
});
describe('actions', () => {
test('fetchUsers should handle successful API call', async () => {
const commit = jest.fn();
const mockApi = {
get: jest.fn().mockResolvedValue({
data: { users: [{ id: 1, name: 'John' }] }
})
};
const context = { commit, $api: mockApi };
await actions.fetchUsers(context);
expect(commit).toHaveBeenCalledWith('SET_LOADING', true);
expect(commit).toHaveBeenCalledWith('SET_USERS', [{ id: 1, name: 'John' }]);
expect(commit).toHaveBeenCalledWith('SET_LOADING', false);
});
test('fetchUsers should handle API errors', async () => {
const commit = jest.fn();
const mockApi = {
get: jest.fn().mockRejectedValue(new Error('API Error'))
};
const context = { commit, $api: mockApi };
await actions.fetchUsers(context);
expect(commit).toHaveBeenCalledWith('SET_ERROR', 'API Error');
expect(commit).toHaveBeenCalledWith('SET_LOADING', false);
});
});
});
端到端测试实战
1. Cypress 基础配置
javascript
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
setupNodeEvents(on, config) {
// 插件配置
},
env: {
apiUrl: 'http://localhost:8080/api'
}
},
component: {
devServer: {
framework: 'react',
bundler: 'webpack'
}
}
});
2. 用户登录流程测试
javascript
// cypress/e2e/auth.cy.js
describe('Authentication', () => {
beforeEach(() => {
cy.visit('/login');
});
it('should login with valid credentials', () => {
// 输入用户名和密码
cy.get('[data-testid="username"]').type('testuser');
cy.get('[data-testid="password"]').type('password123');
// 点击登录按钮
cy.get('[data-testid="login-button"]').click();
// 验证登录成功
cy.url().should('include', '/dashboard');
cy.get('[data-testid="user-menu"]').should('be.visible');
cy.get('[data-testid="welcome-message"]')
.should('contain', 'Welcome, testuser');
});
it('should show error for invalid credentials', () => {
cy.get('[data-testid="username"]').type('invalid');
cy.get('[data-testid="password"]').type('wrong');
cy.get('[data-testid="login-button"]').click();
cy.get('[data-testid="error-message"]')
.should('be.visible')
.and('contain', 'Invalid credentials');
// 确保仍在登录页面
cy.url().should('include', '/login');
});
it('should validate required fields', () => {
cy.get('[data-testid="login-button"]').click();
cy.get('[data-testid="username-error"]')
.should('contain', 'Username is required');
cy.get('[data-testid="password-error"]')
.should('contain', 'Password is required');
});
});
3. 购物车功能测试
javascript
// cypress/e2e/shopping-cart.cy.js
describe('Shopping Cart', () => {
beforeEach(() => {
// 登录用户
cy.login('testuser', 'password123');
cy.visit('/products');
});
it('should add product to cart', () => {
// 选择第一个产品
cy.get('[data-testid="product-card"]').first().within(() => {
cy.get('[data-testid="product-name"]').invoke('text').as('productName');
cy.get('[data-testid="add-to-cart"]').click();
});
// 验证添加成功提示
cy.get('[data-testid="toast"]')
.should('contain', 'Product added to cart');
// 检查购物车图标更新
cy.get('[data-testid="cart-count"]').should('contain', '1');
// 打开购物车
cy.get('[data-testid="cart-icon"]').click();
// 验证产品在购物车中
cy.get('@productName').then((productName) => {
cy.get('[data-testid="cart-item"]')
.should('contain', productName);
});
});
it('should update quantity in cart', () => {
// 添加产品到购物车
cy.get('[data-testid="product-card"]').first().within(() => {
cy.get('[data-testid="add-to-cart"]').click();
});
// 打开购物车
cy.get('[data-testid="cart-icon"]').click();
// 增加数量
cy.get('[data-testid="quantity-increase"]').click();
cy.get('[data-testid="quantity-input"]').should('have.value', '2');
// 减少数量
cy.get('[data-testid="quantity-decrease"]').click();
cy.get('[data-testid="quantity-input"]').should('have.value', '1');
});
it('should complete checkout process', () => {
// 添加产品并进入结账
cy.addProductToCart();
cy.get('[data-testid="checkout-button"]').click();
// 填写配送信息
cy.get('[data-testid="shipping-address"]').type('123 Test Street');
cy.get('[data-testid="shipping-city"]').type('Test City');
cy.get('[data-testid="shipping-zip"]').type('12345');
// 选择支付方式
cy.get('[data-testid="payment-method"]').select('credit-card');
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');
// 提交订单
cy.get('[data-testid="place-order"]').click();
// 验证订单成功
cy.url().should('include', '/order-confirmation');
cy.get('[data-testid="order-success"]')
.should('contain', 'Order placed successfully');
});
});
4. 自定义命令
javascript
// cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
cy.session([username, password], () => {
cy.visit('/login');
cy.get('[data-testid="username"]').type(username);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
});
});
Cypress.Commands.add('addProductToCart', (productIndex = 0) => {
cy.visit('/products');
cy.get('[data-testid="product-card"]').eq(productIndex).within(() => {
cy.get('[data-testid="add-to-cart"]').click();
});
cy.get('[data-testid="toast"]').should('contain', 'Product added to cart');
});
Cypress.Commands.add('clearCart', () => {
cy.get('[data-testid="cart-icon"]').click();
cy.get('[data-testid="clear-cart"]').click();
cy.get('[data-testid="confirm-clear"]').click();
});
测试最佳实践
1. 测试组织
tests/
├── unit/
│ ├── components/
│ ├── utils/
│ └── services/
├── integration/
│ ├── api/
│ └── store/
├── e2e/
│ ├── auth/
│ ├── shopping/
│ └── admin/
└── fixtures/
├── users.json
└── products.json
2. 测试数据管理
javascript
// tests/fixtures/users.js
export const mockUsers = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'user'
},
{
id: 2,
name: 'Jane Admin',
email: 'jane@example.com',
role: 'admin'
}
];
export const createMockUser = (overrides = {}) => ({
id: Math.random(),
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
});
3. 测试工具函数
javascript
// tests/utils/testUtils.js
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { createStore } from 'redux';
// 自定义渲染函数
export function renderWithProviders(
ui,
{
preloadedState = {},
store = createStore(() => preloadedState),
...renderOptions
} = {}
) {
function Wrapper({ children }) {
return (
<Provider store={store}>
<BrowserRouter>
{children}
</BrowserRouter>
</Provider>
);
}
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}
// 等待异步操作
export const waitForLoadingToFinish = () =>
waitFor(() => {
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
// 模拟用户交互
export const userEvent = {
type: async (element, text) => {
await fireEvent.change(element, { target: { value: text } });
},
click: async (element) => {
await fireEvent.click(element);
},
selectOption: async (select, option) => {
await fireEvent.change(select, { target: { value: option } });
}
};
4. 持续集成配置
yaml
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 18.x]
steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Start application
run: npm start &
- name: Wait for application
run: npx wait-on http://localhost:3000
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
性能测试
1. 加载性能测试
javascript
// tests/performance/lighthouse.test.js
const lighthouse = require('lighthouse');
const chromeLauncher = require('chrome-launcher');
describe('Performance Tests', () => {
let chrome;
beforeAll(async () => {
chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
});
afterAll(async () => {
await chrome.kill();
});
test('homepage should meet performance standards', async () => {
const options = {
logLevel: 'info',
output: 'json',
onlyCategories: ['performance'],
port: chrome.port
};
const runnerResult = await lighthouse('http://localhost:3000', options);
const { lhr } = runnerResult;
expect(lhr.categories.performance.score).toBeGreaterThan(0.9);
expect(lhr.audits['first-contentful-paint'].numericValue).toBeLessThan(2000);
expect(lhr.audits['largest-contentful-paint'].numericValue).toBeLessThan(4000);
});
});
2. 内存泄漏测试
javascript
// tests/performance/memory.test.js
describe('Memory Leak Tests', () => {
test('should not leak memory on route changes', async () => {
const initialMemory = performance.memory.usedJSHeapSize;
// 模拟多次路由切换
for (let i = 0; i < 10; i++) {
await navigateToRoute('/products');
await navigateToRoute('/cart');
await navigateToRoute('/profile');
}
// 强制垃圾回收
if (global.gc) {
global.gc();
}
const finalMemory = performance.memory.usedJSHeapSize;
const memoryIncrease = finalMemory - initialMemory;
// 内存增长不应超过 10MB
expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024);
});
});
测试覆盖率
1. 覆盖率配置
javascript
// jest.config.js
module.exports = {
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.js',
'!src/serviceWorker.js',
'!src/**/*.stories.{js,jsx,ts,tsx}'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
'./src/components/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90
}
}
};
2. 覆盖率报告分析
bash
# 生成覆盖率报告
npm run test -- --coverage
# 查看详细报告
open coverage/lcov-report/index.html
覆盖率指标说明:
- 语句覆盖率 (Statements): 执行的语句占总语句的比例
- 分支覆盖率 (Branches): 执行的分支占总分支的比例
- 函数覆盖率 (Functions): 调用的函数占总函数的比例
- 行覆盖率 (Lines): 执行的代码行占总行数的比例
测试调试技巧
1. Jest 调试
javascript
// 使用 console.log 调试
test('debug example', () => {
const result = someFunction();
console.log('Result:', result); // 会在测试输出中显示
expect(result).toBe(expected);
});
// 使用 debugger
test('debugger example', () => {
const result = someFunction();
debugger; // 在 Node.js 调试模式下会暂停
expect(result).toBe(expected);
});
// 使用 screen.debug() 查看 DOM
test('DOM debug example', () => {
render(<MyComponent />);
screen.debug(); // 打印当前 DOM 结构
});
2. Cypress 调试
javascript
// 使用 cy.debug() 暂停执行
cy.get('[data-testid="button"]').debug().click();
// 使用 cy.pause() 暂停测试
cy.pause();
// 在浏览器中查看元素
cy.get('[data-testid="element"]').then(($el) => {
debugger; // 在浏览器开发者工具中暂停
});
测试策略制定
1. 测试计划模板
markdown
# 测试计划
## 项目概述
- 项目名称:
- 测试范围:
- 测试目标:
## 测试策略
- 单元测试覆盖率目标: 80%
- 集成测试重点: API 集成、状态管理
- E2E 测试场景: 关键用户流程
## 测试环境
- 开发环境: localhost:3000
- 测试环境: test.example.com
- 生产环境: example.com
## 测试工具
- 单元测试: Jest + Testing Library
- E2E 测试: Cypress
- 性能测试: Lighthouse
## 测试数据
- 测试用户账号
- 模拟数据集
- API Mock 数据
## 风险评估
- 高风险功能: 支付流程、用户认证
- 中风险功能: 数据展示、表单提交
- 低风险功能: 静态页面、样式调整
2. 测试用例设计
javascript
// 测试用例模板
describe('Feature: User Registration', () => {
describe('Scenario: Valid registration', () => {
test('Given valid user data, When user submits form, Then account is created', () => {
// Arrange (准备)
const userData = {
username: 'testuser',
email: 'test@example.com',
password: 'SecurePass123!'
};
// Act (执行)
const result = registerUser(userData);
// Assert (断言)
expect(result.success).toBe(true);
expect(result.user.id).toBeDefined();
});
});
describe('Scenario: Invalid email format', () => {
test('Given invalid email, When user submits form, Then validation error is shown', () => {
const userData = {
username: 'testuser',
email: 'invalid-email',
password: 'SecurePass123!'
};
expect(() => registerUser(userData))
.toThrow('Invalid email format');
});
});
});
常见问题解决
1. 异步测试问题
javascript
// ❌ 错误:没有等待异步操作
test('async test - wrong', () => {
fetchData().then(data => {
expect(data).toBeDefined();
});
});
// ✅ 正确:使用 async/await
test('async test - correct', async () => {
const data = await fetchData();
expect(data).toBeDefined();
});
// ✅ 正确:返回 Promise
test('async test - promise', () => {
return fetchData().then(data => {
expect(data).toBeDefined();
});
});
2. 时间相关测试
javascript
// 使用 Jest 的时间模拟
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('should call callback after timeout', () => {
const callback = jest.fn();
setTimeout(callback, 1000);
// 快进时间
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
});
3. 网络请求模拟
javascript
// 使用 MSW (Mock Service Worker)
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());
测试自动化
1. Git Hooks
bash
# .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run test:unit
npm run lint
2. 测试脚本
json
{
"scripts": {
"test": "jest",
"test:unit": "jest --testPathPattern=unit",
"test:integration": "jest --testPathPattern=integration",
"test:e2e": "cypress run",
"test:e2e:open": "cypress open",
"test:coverage": "jest --coverage",
"test:watch": "jest --watch",
"test:ci": "jest --ci --coverage --watchAll=false"
}
}
总结
前端自动化测试是保证代码质量的重要手段。关键要点:
- 遵循测试金字塔:大量单元测试,适量集成测试,少量 E2E 测试
- 选择合适工具:根据项目需求选择测试框架和工具
- 编写可维护的测试:清晰的测试结构,合理的测试数据管理
- 持续改进:定期审查测试覆盖率,优化测试策略
通过系统性的测试实践,可以显著提高代码质量,减少生产环境问题,提升开发效率。