前端测试¶
📚 章节目标¶
本章节将全面介绍前端测试的各种技术和工具,包括 Jest 、 Cypress 、 Playwright 、测试覆盖率等,帮助学习者掌握前端测试的核心方法。
学习目标¶
- 理解前端测试的核心概念
- 掌握 Jest 单元测试
- 掌握 Cypress E2E 测试
- 掌握 Playwright 自动化测试
- 掌握测试覆盖率分析
- 理解测试最佳实践
🧪 前端测试概述¶
1. 测试类型¶
JavaScript
// 前端测试类型
// 1. 单元测试 - 测试独立的功能单元
// 2. 集成测试 - 测试多个组件的交互
// 3. E2E测试 - 测试完整的用户流程
// 4. 视觉回归测试 - 测试UI外观
// 5. 性能测试 - 测试应用性能
// 单元测试示例
function add(a, b) {
return a + b;
}
test('adds 1 + 2 to equal 3', () => {
expect(add(1, 2)).toBe(3);
});
// 集成测试示例
test('user can login and see dashboard', () => {
render(<App />);
fireEvent.change(screen.getByLabelText('Username'), {
target: { value: 'testuser' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'password' },
});
fireEvent.click(screen.getByText('Login'));
expect(screen.getByText('Dashboard')).toBeInTheDocument();
});
// E2E测试示例
test('user can complete purchase flow', async () => {
await page.goto('https://example.com');
await page.click('text=Products');
await page.click('text=Add to Cart');
await page.click('text=Checkout');
await page.fill('input[name="email"]', 'test@example.com');
await page.click('text=Place Order');
await expect(page.locator('text=Order Confirmed')).toBeVisible();
});
2. 测试金字塔¶
Text Only
/\
/E2E\ 少量E2E测试
/------\
/ 集成测试 \ 适量集成测试
/------------\
/ 单元测试 \ 大量单元测试
/----------------\
📝 Jest 单元测试¶
1. Jest 基础¶
1.1 基本配置¶
JavaScript
// jest.config.js
module.exports = {
// 测试环境
testEnvironment: 'jsdom',
// 测试文件匹配
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
// 转换配置
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
'^.+\\.(js|jsx)$': 'babel-jest',
},
// 模块路径别名
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
// 覆盖率配置
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/main.{js,jsx,ts,tsx}',
'!src/**/*.stories.{js,jsx,ts,tsx}',
],
// 覆盖率阈值
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
1.2 基本测试¶
JavaScript
// math.test.js
import { add, subtract, multiply, divide } from './math';
describe('Math functions', () => {
test('adds 1 + 2 to equal 3', () => {
expect(add(1, 2)).toBe(3);
});
test('subtracts 5 - 3 to equal 2', () => {
expect(subtract(5, 3)).toBe(2);
});
test('multiplies 2 * 3 to equal 6', () => {
expect(multiply(2, 3)).toBe(6);
});
test('divides 6 / 2 to equal 3', () => {
expect(divide(6, 2)).toBe(3);
});
test('divides by zero throws error', () => {
expect(() => divide(6, 0)).toThrow('Cannot divide by zero');
});
});
2. React 组件测试¶
2.1 使用 React Testing Library¶
JavaScript
// Button.test.js
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.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Click me</Button>);
expect(screen.getByText('Click me')).toBeDisabled();
});
});
2.2 测试表单¶
JavaScript
// LoginForm.test.js
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import LoginForm from './LoginForm';
describe('LoginForm component', () => {
test('renders login form', () => {
render(<LoginForm />);
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByText('Login')).toBeInTheDocument();
});
test('shows error when fields are empty', async () => {
render(<LoginForm />);
fireEvent.click(screen.getByText('Login'));
await waitFor(() => {
expect(screen.getByText('Username is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
});
test('submits form with valid data', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
fireEvent.change(screen.getByLabelText('Username'), {
target: { value: 'testuser' },
});
fireEvent.change(screen.getByLabelText('Password'), {
target: { value: 'password' },
});
fireEvent.click(screen.getByText('Login'));
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password',
});
});
});
});
3. 异步测试¶
3.1 测试异步函数¶
JavaScript
// api.test.js
import { fetchUsers, fetchUser } from './api';
describe('API functions', () => {
test('fetchUsers returns array of users', async () => {
const users = await fetchUsers();
expect(Array.isArray(users)).toBe(true);
expect(users.length).toBeGreaterThan(0);
});
test('fetchUser returns user object', async () => {
const user = await fetchUser(1);
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('email');
});
});
3.2 Mock 异步请求¶
JavaScript
// api.test.js
import { fetchUsers } from './api';
// Mock fetch
global.fetch = jest.fn();
describe('fetchUsers with mock', () => {
beforeEach(() => {
fetch.mockClear();
});
test('fetches users from API', async () => {
const mockUsers = [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
];
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUsers,
});
const users = await fetchUsers();
expect(users).toEqual(mockUsers);
expect(fetch).toHaveBeenCalledTimes(1);
});
test('handles API error', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
await expect(fetchUsers()).rejects.toThrow('Network error');
});
});
4. Mock 和 Stub¶
4.1 Mock 函数¶
JavaScript
// 使用jest.fn()
const mockFn = jest.fn(); // const不可重新赋值;let块级作用域变量
mockFn('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
// Mock返回值
const mockFn = jest.fn().mockReturnValue('result');
expect(mockFn()).toBe('result');
// Mock异步返回值
const mockFn = jest.fn().mockResolvedValue('result');
await expect(mockFn()).resolves.toBe('result');
// Mock实现
const mockFn = jest.fn().mockImplementation((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);
4.2 Mock 模块¶
JavaScript
// Mock整个模块
jest.mock('./api', () => ({
fetchUsers: jest.fn(() => Promise.resolve([])), // Promise异步操作容器:pending→fulfilled/rejected
fetchUser: jest.fn((id) => Promise.resolve({ id, name: 'Test' })),
}));
// Mock部分模块
jest.mock('./api', () => ({
...jest.requireActual('./api'),
fetchUsers: jest.fn(() => Promise.resolve([])),
}));
⚡ Vitest 单元测试¶
推荐场景: Vite 项目首选 Vitest ,与 Jest API 高度兼容,无需额外配置即可 TypeScript/ESM 友好。
1. Vitest 基础¶
1.1 基本配置¶
TypeScript
// vite.config.ts(在 Vite 配置中集成 Vitest)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
// 测试环境(浏览器 API 模拟)
environment: 'jsdom',
// 全局 API(无需在每个文件 import describe/it/expect)
globals: true,
// 测试前执行的设置文件
setupFiles: ['./src/test/setup.ts'],
// 覆盖率配置
coverage: {
provider: 'v8', // 使用 V8 内置覆盖率(比 istanbul 更快)
reporter: ['text', 'html', 'lcov'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
},
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // 扩展 expect 断言(toBeInTheDocument 等)
1.2 基本测试¶
TypeScript
// math.test.ts
import { describe, it, expect } from 'vitest';
import { add, divide } from './math';
describe('Math utils', () => {
it('adds two numbers', () => {
expect(add(1, 2)).toBe(3);
});
it('throws on divide by zero', () => {
expect(() => divide(1, 0)).toThrow('Cannot divide by zero');
});
});
2. React 组件测试( Vitest + Testing Library )¶
TSX
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick handler', () => {
const handleClick = vi.fn(); // vi.fn() 等同于 jest.fn()
render(<Button onClick={handleClick}>Submit</Button>);
fireEvent.click(screen.getByText('Submit'));
expect(handleClick).toHaveBeenCalledOnce();
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
});
3. Mock 与 Spy¶
TypeScript
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Mock 模块(与 jest.mock 语法相同)
vi.mock('./api', () => ({
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
}));
// 监听函数调用(不改变实现)
const spy = vi.spyOn(console, 'log');
console.log('test');
expect(spy).toHaveBeenCalledWith('test');
// 定时器控制
vi.useFakeTimers();
setTimeout(() => { /* ... */ }, 1000);
vi.advanceTimersByTime(1000); // 快进 1 秒
vi.useRealTimers(); // 恢复真实定时器
// 快照测试(自动生成并比对 DOM 快照)
import { render } from '@testing-library/react';
it('matches snapshot', () => {
const { container } = render(<MyComponent />);
expect(container).toMatchInlineSnapshot();
});
4. Vitest 与 Jest 对比¶
| 特性 | Vitest | Jest |
|---|---|---|
| 原生 ESM 支持 | ✅ 原生 | ⚠️ 需配置 |
| TypeScript | ✅ 零配置 | ⚠️ 需 ts-jest |
| Vite 配置复用 | ✅ 自动 | ❌ 独立配置 |
| 热模块重载 | ✅ Watch 模式极快 | ⚠️ 较慢 |
| API 兼容性 | 与 Jest 高度兼容 | 原版 |
| 并行执行 | ✅ 默认并行 | ✅ 需配置 |
🌐 Cypress E2E 测试¶
1. Cypress 基础¶
1.1 基本配置¶
JavaScript
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
});
1.2 基本测试¶
JavaScript
// cypress/e2e/login.cy.js
describe('Login Flow', () => {
beforeEach(() => {
cy.visit('/login');
});
it('should login with valid credentials', () => {
cy.get('[data-testid="username"]').type('testuser');
cy.get('[data-testid="password"]').type('password');
cy.get('[data-testid="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome, testuser').should('be.visible');
});
it('should show error with invalid credentials', () => {
cy.get('[data-testid="username"]').type('invalid');
cy.get('[data-testid="password"]').type('invalid');
cy.get('[data-testid="submit"]').click();
cy.contains('Invalid credentials').should('be.visible');
});
});
2. 高级用法¶
2.1 自定义命令¶
JavaScript
// cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
cy.get('[data-testid="username"]').type(username);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="submit"]').click();
});
// 使用
cy.login('testuser', 'password');
2.2 API 测试¶
JavaScript
describe('API Testing', () => {
it('should fetch users', () => {
cy.request('/api/users').then((response) => {
expect(response.status).to.eq(200);
expect(response.body).to.be.an('array');
});
});
it('should create user', () => {
cy.request('POST', '/api/users', {
name: 'Test User',
email: 'test@example.com',
}).then((response) => {
expect(response.status).to.eq(201);
expect(response.body).to.have.property('id');
});
});
});
🎭 Playwright 自动化测试¶
1. Playwright 基础¶
1.1 基本配置¶
JavaScript
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test'); // 解构赋值:从对象/数组提取值
module.exports = defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }, // ...展开运算符:展开数组/对象
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
1.2 基本测试¶
JavaScript
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test('should login with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="username"]', 'testuser');
await page.fill('[data-testid="password"]', 'password');
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.locator('text=Welcome, testuser')).toBeVisible();
});
test('should show error with invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="username"]', 'invalid');
await page.fill('[data-testid="password"]', 'invalid');
await page.click('[data-testid="submit"]');
await expect(page.locator('text=Invalid credentials')).toBeVisible();
});
});
2. 高级用法¶
2.1 多浏览器测试¶
JavaScript
test.describe('Cross-browser testing', () => {
test('should work on all browsers', async ({ page, browserName }) => {
await page.goto('/');
await expect(page.locator('h1')).toHaveText('Welcome');
console.log(`Testing on ${browserName}`);
});
});
2.2 网络拦截¶
JavaScript
test('should intercept API requests', async ({ page }) => { // async定义异步函数;await等待Promise完成
await page.route('**/api/users', route => { // await等待异步操作完成
route.fulfill({
status: 200,
body: JSON.stringify([{ id: 1, name: 'Test User' }]),
});
});
await page.goto('/users');
await expect(page.locator('text=Test User')).toBeVisible();
});
📊 测试覆盖率¶
1. Jest 覆盖率¶
JavaScript
// jest.config.js
module.exports = {
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/main.{js,jsx,ts,tsx}',
],
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
// 运行测试并生成覆盖率
npm test -- --coverage
2. Istanbul 覆盖率¶
JavaScript
// 使用nyc生成覆盖率
npm install -D nyc
// package.json
{
"scripts": {
"test:coverage": "nyc npm test"
}
}
// nyc配置
{
"reporter": ["text", "html", "lcov"],
"include": ["src/**/*.js"],
"exclude": ["src/**/*.test.js"]
}
📝 练习题¶
1. 基础题¶
题目 1 :编写一个单元测试
JavaScript
// math.js
export function add(a, b) {
return a + b;
}
// math.test.js
test('adds 1 + 2 to equal 3', () => {
// 编写测试
});
2. 进阶题¶
题目 2 :编写一个 React 组件测试
JavaScript
// Button.js
export function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
// Button.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
test('calls onClick when clicked', () => { // 箭头函数:简洁的函数语法
// 编写测试
});
3. 面试题¶
题目 3 :解释单元测试、集成测试和 E2E 测试的区别
JavaScript
// 答案要点:
// 1. 单元测试:测试独立的功能单元,速度快,隔离性好
// 2. 集成测试:测试多个组件的交互,速度中等,测试集成点
// 3. E2E测试:测试完整的用户流程,速度慢,测试真实场景
// 4. 测试金字塔:大量单元测试,适量集成测试,少量E2E测试
🎯 本章总结¶
本章节全面介绍了前端测试的各种技术和工具,包括 Jest 、 Cypress 、 Playwright 、测试覆盖率等。关键要点:
- 测试类型:理解单元测试、集成测试、 E2E 测试的区别
- Jest:掌握 Jest 单元测试和 React 组件测试
- Cypress:掌握 Cypress E2E 测试
- Playwright:掌握 Playwright 自动化测试
- 测试覆盖率:掌握测试覆盖率分析
- 最佳实践:理解测试金字塔和测试最佳实践
下一步将深入学习前端监控技术。