前端测试¶
📚 章节目标¶
本章节将全面介绍前端测试的各种技术和工具,包括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([])),
}));
🌐 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自动化测试
- 测试覆盖率:掌握测试覆盖率分析
- 最佳实践:理解测试金字塔和测试最佳实践
下一步将深入学习前端监控技术。