前端自动化测试
约 1985 字大约 7 分钟
前端自动化测试
单元测试基础 🟢
1. Jest基础配置
Jest是一个流行的JavaScript测试框架,它提供了完整的测试解决方案。以下是详细的配置和使用说明:
- 基础配置:
- 安装依赖
- 配置测试环境
- 设置测试覆盖率
- 配置测试匹配器
- 配置示例:
// jest.config.js
module.exports = {
// 测试环境
testEnvironment: 'jsdom',
// 测试文件匹配
testMatch: [
'**/__tests__/**/*.test.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)'
],
// 覆盖率收集
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/serviceWorker.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// 模块转换
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
'^.+\\.css$': 'jest-transform-css',
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': 'jest-transform-file'
},
// 模块路径映射
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
// 设置测试环境
setupFilesAfterEnv: [
'<rootDir>/src/setupTests.ts'
]
};
2. 编写基础测试用例
- 测试函数和方法:
// math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// math.test.js
import { add, subtract } from './math';
describe('Math functions', () => {
describe('add', () => {
test('adds two positive numbers', () => {
expect(add(1, 2)).toBe(3);
});
test('adds positive and negative numbers', () => {
expect(add(1, -2)).toBe(-1);
});
test('adds two negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
});
describe('subtract', () => {
test('subtracts two positive numbers', () => {
expect(subtract(3, 2)).toBe(1);
});
test('subtracts with negative result', () => {
expect(subtract(1, 2)).toBe(-1);
});
});
});
- 测试异步代码:
// api.js
export async function fetchUser(id) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
}
// api.test.js
import { fetchUser } from './api';
describe('API functions', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
test('fetches user successfully', async () => {
const mockUser = { id: 1, name: 'John' };
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser
});
const user = await fetchUser(1);
expect(user).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith('/api/users/1');
});
test('handles user not found', async () => {
global.fetch.mockResolvedValueOnce({
ok: false
});
await expect(fetchUser(1)).rejects.toThrow('User not found');
});
});
组件测试 🟡
1. React组件测试
使用React Testing Library进行组件测试,重点关注用户交互和组件行为:
- 基础组件测试:
// Button.tsx
import React from 'react';
interface ButtonProps {
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
onClick,
disabled = false,
children
}) => (
<button
onClick={onClick}
disabled={disabled}
className={`btn ${disabled ? 'btn-disabled' : ''}`}
>
{children}
</button>
);
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button component', () => {
test('renders button with text', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toBeInTheDocument();
});
test('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
const button = screen.getByText('Click me');
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('can be disabled', () => {
const handleClick = jest.fn();
render(
<Button onClick={handleClick} disabled>
Click me
</Button>
);
const button = screen.getByText('Click me');
expect(button).toBeDisabled();
fireEvent.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});
- 异步组件测试:
// UserProfile.tsx
import React, { useEffect, useState } from 'react';
import { fetchUser } from './api';
interface User {
id: number;
name: string;
email: string;
}
export const UserProfile: React.FC<{ userId: number }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadUser = async () => {
try {
setLoading(true);
const data = await fetchUser(userId);
setUser(data);
setError(null);
} catch (err) {
setError(err.message);
setUser(null);
} finally {
setLoading(false);
}
};
loadUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
};
// UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import { fetchUser } from './api';
jest.mock('./api');
describe('UserProfile component', () => {
const mockUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
beforeEach(() => {
jest.resetAllMocks();
});
test('shows loading state initially', () => {
(fetchUser as jest.Mock).mockImplementation(
() => new Promise(() => {})
);
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('displays user data when loaded', async () => {
(fetchUser as jest.Mock).mockResolvedValue(mockUser);
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(mockUser.name)).toBeInTheDocument();
expect(screen.getByText(mockUser.email)).toBeInTheDocument();
});
});
test('shows error message on failure', async () => {
const error = new Error('Failed to fetch user');
(fetchUser as jest.Mock).mockRejectedValue(error);
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(`Error: ${error.message}`))
.toBeInTheDocument();
});
});
test('refetches when userId changes', async () => {
(fetchUser as jest.Mock).mockResolvedValue(mockUser);
const { rerender } = render(<UserProfile userId={1} />);
await waitFor(() => {
expect(fetchUser).toHaveBeenCalledWith(1);
});
rerender(<UserProfile userId={2} />);
await waitFor(() => {
expect(fetchUser).toHaveBeenCalledWith(2);
});
});
});
集成测试 🔴
1. API集成测试
使用Jest和Supertest进行API集成测试:
// server.ts
import express from 'express';
import { createUser, getUser } from './userController';
const app = express();
app.use(express.json());
app.post('/api/users', createUser);
app.get('/api/users/:id', getUser);
export default app;
// server.test.ts
import request from 'supertest';
import app from './server';
import { User } from './types';
import { connectDB, closeDB, clearDB } from './testUtils';
describe('User API', () => {
beforeAll(async () => {
await connectDB();
});
afterEach(async () => {
await clearDB();
});
afterAll(async () => {
await closeDB();
});
describe('POST /api/users', () => {
test('creates a new user', async () => {
const userData: Partial<User> = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toMatchObject({
name: userData.name,
email: userData.email
});
expect(response.body).not.toHaveProperty('password');
expect(response.body).toHaveProperty('id');
});
test('validates required fields', async () => {
const response = await request(app)
.post('/api/users')
.send({})
.expect(400);
expect(response.body).toHaveProperty('errors');
expect(response.body.errors).toContain('Name is required');
expect(response.body.errors).toContain('Email is required');
expect(response.body.errors).toContain('Password is required');
});
test('prevents duplicate emails', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
};
await request(app)
.post('/api/users')
.send(userData)
.expect(201);
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(409);
expect(response.body).toHaveProperty(
'message',
'Email already exists'
);
});
});
describe('GET /api/users/:id', () => {
test('retrieves an existing user', async () => {
// First create a user
const createResponse = await request(app)
.post('/api/users')
.send({
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
});
const userId = createResponse.body.id;
// Then retrieve the user
const response = await request(app)
.get(`/api/users/${userId}`)
.expect(200);
expect(response.body).toMatchObject({
id: userId,
name: 'John Doe',
email: 'john@example.com'
});
expect(response.body).not.toHaveProperty('password');
});
test('handles non-existent users', async () => {
const response = await request(app)
.get('/api/users/999')
.expect(404);
expect(response.body).toHaveProperty(
'message',
'User not found'
);
});
});
});
2. 端到端测试
使用Cypress进行端到端测试:
// cypress/integration/auth.spec.ts
describe('Authentication', () => {
beforeEach(() => {
cy.visit('/login');
});
it('successfully logs in', () => {
cy.intercept('POST', '/api/login').as('loginRequest');
cy.get('[data-testid=email-input]')
.type('user@example.com');
cy.get('[data-testid=password-input]')
.type('password123');
cy.get('[data-testid=login-button]')
.click();
cy.wait('@loginRequest')
.its('response.statusCode')
.should('eq', 200);
cy.url().should('include', '/dashboard');
cy.get('[data-testid=user-menu]')
.should('contain', 'Welcome, User');
});
it('displays validation errors', () => {
cy.get('[data-testid=login-button]').click();
cy.get('[data-testid=email-error]')
.should('be.visible')
.and('contain', 'Email is required');
cy.get('[data-testid=password-error]')
.should('be.visible')
.and('contain', 'Password is required');
});
it('handles invalid credentials', () => {
cy.intercept('POST', '/api/login', {
statusCode: 401,
body: {
message: 'Invalid credentials'
}
}).as('loginRequest');
cy.get('[data-testid=email-input]')
.type('wrong@example.com');
cy.get('[data-testid=password-input]')
.type('wrongpassword');
cy.get('[data-testid=login-button]')
.click();
cy.wait('@loginRequest');
cy.get('[data-testid=login-error]')
.should('be.visible')
.and('contain', 'Invalid credentials');
});
});
// cypress/integration/dashboard.spec.ts
describe('Dashboard', () => {
beforeEach(() => {
// 登录并访问仪表板
cy.login();
cy.visit('/dashboard');
});
it('displays user data', () => {
cy.intercept('GET', '/api/user/stats', {
fixture: 'userStats.json'
}).as('getUserStats');
cy.wait('@getUserStats');
cy.get('[data-testid=stats-card]')
.should('have.length', 4);
cy.get('[data-testid=total-orders]')
.should('contain', '150');
cy.get('[data-testid=revenue]')
.should('contain', '$15,000');
});
it('handles data loading states', () => {
cy.intercept('GET', '/api/user/stats', {
delay: 1000,
fixture: 'userStats.json'
}).as('getUserStats');
cy.get('[data-testid=loading-spinner]')
.should('be.visible');
cy.wait('@getUserStats');
cy.get('[data-testid=loading-spinner]')
.should('not.exist');
cy.get('[data-testid=stats-card]')
.should('be.visible');
});
it('handles error states', () => {
cy.intercept('GET', '/api/user/stats', {
statusCode: 500,
body: {
message: 'Internal server error'
}
}).as('getUserStats');
cy.wait('@getUserStats');
cy.get('[data-testid=error-message]')
.should('be.visible')
.and('contain', 'Failed to load dashboard data');
cy.get('[data-testid=retry-button]')
.should('be.visible')
.click();
cy.get('@getUserStats.all').should('have.length', 2);
});
});
测试最佳实践 🔴
1. 测试策略
- 测试金字塔:
- 单元测试(底层):最多,最快
- 集成测试(中层):中等数量
- 端到端测试(顶层):最少,最慢
- 测试优先级:
- 关键业务逻辑
- 复杂的计算逻辑
- 边界条件和错误处理
- 用户关键路径
- 测试覆盖率目标:
- 语句覆盖率:80%以上
- 分支覆盖率:70%以上
- 函数覆盖率:85%以上
2. 测试代码质量
- 测试代码组织:
// 使用describe块组织测试
describe('UserService', () => {
describe('create', () => {
describe('with valid data', () => {
test('creates a new user', () => {
// 测试代码
});
test('sends welcome email', () => {
// 测试代码
});
});
describe('with invalid data', () => {
test('throws validation error', () => {
// 测试代码
});
});
});
});
- 测试命名规范:
// 好的测试命名示例
describe('OrderService', () => {
test('calculates total with tax and shipping', () => {});
test('applies discount when total exceeds threshold', () => {});
test('throws error when items array is empty', () => {});
});
- 测试数据管理:
// 测试数据工厂
class TestDataFactory {
static createUser(overrides = {}) {
return {
id: Math.random().toString(36).substr(2, 9),
name: 'Test User',
email: 'test@example.com',
role: 'user',
...overrides
};
}
static createOrder(overrides = {}) {
return {
id: Math.random().toString(36).substr(2, 9),
userId: this.createUser().id,
items: [],
status: 'pending',
createdAt: new Date(),
...overrides
};
}
}
// 在测试中使用
describe('OrderService', () => {
test('processes order successfully', () => {
const user = TestDataFactory.createUser();
const order = TestDataFactory.createOrder({ userId: user.id });
// 测试代码
});
});
3. 持续集成测试
- CI配置示例:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Integration tests
run: npm run test:integration
- name: E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
token: ${{ secrets.CODECOV_TOKEN }}
- 测试报告生成:
// jest.config.js
module.exports = {
reporters: [
'default',
[
'jest-junit',
{
outputDirectory: 'reports/junit',
outputName: 'jest-junit.xml',
classNameTemplate: '{classname}',
titleTemplate: '{title}',
ancestorSeparator: ' › ',
usePathForSuiteName: true
}
],
[
'./node_modules/jest-html-reporter',
{
pageTitle: 'Test Report',
outputPath: 'reports/test-report.html',
includeFailureMsg: true,
includeSuiteFailure: true
}
]
]
};
通过以上内容,我们详细介绍了前端自动化测试的各个方面,包括:
- 单元测试的基础配置和实现
- React组件的测试方法
- API集成测试的实现
- 端到端测试的编写
- 测试最佳实践和持续集成配置
这些内容可以帮助开发团队建立完善的测试体系,提高代码质量和可维护性。