前端单元测试实战指南
Created on
前言
“我的代码没问题,不需要测试”——这是很多前端开发者的想法。然而,当项目规模增长、需求频繁变更时,没有测试的代码就像没有保险的汽车,随时可能翻车。本文将系统讲解前端单元测试的理论和实践。
为什么需要单元测试?
真实案例
某电商网站的购物车功能上线后,用户反馈优惠券计算错误。排查发现,是因为某次重构时修改了价格计算逻辑,导致优惠券叠加规则出错。如果有单元测试,这个问题在提交代码时就会被发现。
单元测试的价值
- 提前发现 Bug:在开发阶段而非生产环境发现问题
- 重构保障:重构时快速验证功能未被破坏
- 文档作用:测试用例展示代码的使用方式
- 提高代码质量:可测试的代码通常设计更好
- 节省时间:自动化测试比手动测试快得多
测试金字塔
/\
/E2E\ 少量 E2E 测试(慢、覆盖完整流程)
/______\
/ \
/Integration\ 适量集成测试(中速、验证模块协作)
/____________\
/ \
/ Unit Tests \ 大量单元测试(快、覆盖单一功能)
/__________________\
推荐比例
- 单元测试:70%
- 集成测试:20%
- E2E 测试:10%
Jest vs Vitest
Jest
# 安装
npm install --save-dev jest @types/jest
# 配置 jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
'^@/(.*)$': '<rootDir>/src/$1'
},
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx'
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Vitest(推荐,Vite 项目)
# 安装
npm install --save-dev vitest @vitest/ui
# 配置 vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/']
}
}
});
基础测试示例
1. 纯函数测试
// utils/math.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error("Division by zero");
}
return a / b;
}
// utils/math.test.js
import { add, multiply, divide } from "./math";
describe("Math Utils", () => {
describe("add", () => {
it("should add two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
it("should add negative numbers", () => {
expect(add(-2, -3)).toBe(-5);
});
it("should handle zero", () => {
expect(add(0, 5)).toBe(5);
});
});
describe("multiply", () => {
it("should multiply two numbers", () => {
expect(multiply(3, 4)).toBe(12);
});
it("should handle multiplication by zero", () => {
expect(multiply(5, 0)).toBe(0);
});
});
describe("divide", () => {
it("should divide two numbers", () => {
expect(divide(10, 2)).toBe(5);
});
it("should throw error when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});
});
});
2. 异步函数测试
// api/user.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/user.test.js
import { fetchUser } from "./user";
describe("fetchUser", () => {
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.resetAllMocks();
});
it("should fetch user successfully", async () => {
const mockUser = { id: 1, name: "John" };
global.fetch.mockResolvedValue({
ok: true,
json: async () => mockUser,
});
const user = await fetchUser(1);
expect(user).toEqual(mockUser);
expect(global.fetch).toHaveBeenCalledWith("/api/users/1");
});
it("should throw error when user not found", async () => {
global.fetch.mockResolvedValue({
ok: false,
});
await expect(fetchUser(999)).rejects.toThrow("User not found");
});
});
React 组件测试
使用 React Testing Library
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
测试用户交互
// components/Counter.jsx
import { useState } from "react";
export function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// components/Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
describe("Counter", () => {
it("should render with initial count", () => {
render(<Counter initialCount={5} />);
expect(screen.getByText("Count: 5")).toBeInTheDocument();
});
it("should increment count", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByText("Increment"));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
it("should decrement count", async () => {
const user = userEvent.setup();
render(<Counter initialCount={5} />);
await user.click(screen.getByText("Decrement"));
expect(screen.getByText("Count: 4")).toBeInTheDocument();
});
it("should reset count", async () => {
const user = userEvent.setup();
render(<Counter initialCount={10} />);
await user.click(screen.getByText("Reset"));
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
});
测试表单
// components/LoginForm.jsx
import { useState } from "react";
export function LoginForm({ onSubmit }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (!email || !password) {
setError("All fields are required");
return;
}
if (!/\S+@\S+\.\S+/.test(email)) {
setError("Invalid email format");
return;
}
setError("");
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Login</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
// components/LoginForm.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("should show error when fields are empty", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByText("Login"));
expect(screen.getByRole("alert")).toHaveTextContent(
"All fields are required"
);
expect(onSubmit).not.toHaveBeenCalled();
});
it("should show error for invalid email", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "invalid-email");
await user.type(screen.getByPlaceholderText("Password"), "password123");
await user.click(screen.getByText("Login"));
expect(screen.getByRole("alert")).toHaveTextContent("Invalid email format");
expect(onSubmit).not.toHaveBeenCalled();
});
it("should submit form with valid data", async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "[email protected]");
await user.type(screen.getByPlaceholderText("Password"), "password123");
await user.click(screen.getByText("Login"));
expect(onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
password: "password123",
});
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
Mock 和 Spy
Mock 函数
// 创建 mock 函数
const mockFn = jest.fn();
// 设置返回值
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
// 设置异步返回值
mockFn.mockResolvedValue({ data: "success" });
await expect(mockFn()).resolves.toEqual({ data: "success" });
// 设置多次调用的返回值
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);
expect(mockFn()).toBe(1);
expect(mockFn()).toBe(2);
expect(mockFn()).toBe(3);
// 检查调用
expect(mockFn).toHaveBeenCalledTimes(3);
expect(mockFn).toHaveBeenCalledWith(expectedArg);
Mock 模块
// api/userService.js
export const getUserById = async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
// components/UserProfile.test.jsx
import { getUserById } from "../api/userService";
jest.mock("../api/userService");
describe("UserProfile", () => {
it("should display user name", async () => {
getUserById.mockResolvedValue({ id: 1, name: "John Doe" });
render(<UserProfile userId={1} />);
expect(await screen.findByText("John Doe")).toBeInTheDocument();
});
});
Spy 监听
const calculator = {
add: (a, b) => a + b,
multiply: (a, b) => a * b,
};
const addSpy = jest.spyOn(calculator, "add");
calculator.add(2, 3);
expect(addSpy).toHaveBeenCalledWith(2, 3);
expect(addSpy).toHaveReturnedWith(5);
addSpy.mockRestore(); // 恢复原始实现
Hooks 测试
import { renderHook, waitFor } from "@testing-library/react";
import { useCounter } from "./useCounter";
describe("useCounter", () => {
it("should increment counter", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("should fetch data", async () => {
global.fetch = jest.fn().mockResolvedValue({
json: async () => ({ data: "test" }),
});
const { result } = renderHook(() => useFetch("/api/data"));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ data: "test" });
});
});
TDD 开发流程
Red-Green-Refactor
// 1. Red - 编写失败的测试
describe("calculateDiscount", () => {
it("should apply 10% discount for orders over $100", () => {
expect(calculateDiscount(150)).toBe(135);
});
});
// 2. Green - 编写最简单的实现让测试通过
function calculateDiscount(amount) {
if (amount > 100) {
return amount * 0.9;
}
return amount;
}
// 3. Refactor - 重构代码
function calculateDiscount(amount) {
const DISCOUNT_THRESHOLD = 100;
const DISCOUNT_RATE = 0.1;
if (amount > DISCOUNT_THRESHOLD) {
return amount * (1 - DISCOUNT_RATE);
}
return amount;
}
测试覆盖率
package.json 配置
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
查看覆盖率报告
npm run test:coverage
# 生成 HTML 报告
open coverage/index.html
合理的覆盖率目标
- 核心业务逻辑:90-100%
- 工具函数:80-90%
- UI 组件:60-80%
- 配置文件:不强求
E2E 测试
Playwright 示例
npm install --save-dev @playwright/test
// e2e/login.spec.js
import { test, expect } from "@playwright/test";
test.describe("Login Flow", () => {
test("should login successfully", async ({ page }) => {
await page.goto("http://localhost:3000/login");
await page.fill('input[name="email"]', "[email protected]");
await page.fill('input[name="password"]', "password123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL("http://localhost:3000/dashboard");
await expect(page.locator("h1")).toContainText("Welcome");
});
test("should show error for invalid credentials", async ({ page }) => {
await page.goto("http://localhost:3000/login");
await page.fill('input[name="email"]', "[email protected]");
await page.fill('input[name="password"]', "wrongpassword");
await page.click('button[type="submit"]');
await expect(page.locator('[role="alert"]')).toContainText(
"Invalid credentials"
);
});
});
最佳实践
1. 测试命名规范
// ✅ 好的命名
describe("UserService", () => {
describe("createUser", () => {
it("should create user with valid data", () => {});
it("should throw error when email is duplicate", () => {});
it("should hash password before saving", () => {});
});
});
// ❌ 不好的命名
describe("test1", () => {
it("works", () => {});
});
2. AAA 模式
it("should add item to cart", () => {
// Arrange - 准备测试数据
const cart = new ShoppingCart();
const item = { id: 1, name: "Book", price: 20 };
// Act - 执行操作
cart.addItem(item);
// Assert - 验证结果
expect(cart.items).toContain(item);
expect(cart.total).toBe(20);
});
3. 避免测试实现细节
// ❌ 测试实现细节
it("should call setState with correct value", () => {
const setStateSpy = jest.spyOn(component, "setState");
component.handleClick();
expect(setStateSpy).toHaveBeenCalledWith({ count: 1 });
});
// ✅ 测试用户行为
it("should increment counter when button is clicked", async () => {
render(<Counter />);
await user.click(screen.getByText("Increment"));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
4. 使用 data-testid 谨慎
// ❌ 过度使用 data-testid
<button data-testid="submit-button">Submit</button>
// ✅ 优先使用语义化查询
<button type="submit">Submit</button>
// 在测试中
screen.getByRole('button', { name: /submit/i });
CI/CD 集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: "npm"
- run: npm ci
- run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
总结
前端单元测试的核心原则:
- 测试行为,不测实现
- 保持测试简单、快速
- 遵循测试金字塔原则
- 持续维护测试代码
- TDD 能写出更好的代码
记住:测试不是浪费时间,而是节省时间的投资。