单元测试进阶概述
深度单元测试能有效保障代码质量。Claude Code 可以帮你编写高质量的测试用例。
测试金字塔
单元测试 → 集成测试 → E2E 测试,比例约为 70:20:10。
测试框架配置
Vitest 配置
bash
帮我创建一个 React + Vitest 的测试配置。javascript
// vitest.config.js
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.js"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"node_modules/",
"src/test/",
"**/*.d.ts",
"**/*.config.*",
".eslintrc.cjs",
],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
statements: 80,
},
},
include: ["src/**/*.{test,spec}.{js,jsx,ts,tsx}"],
exclude: ["node_modules", "dist"],
testTimeout: 10000,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});测试设置文件
javascript
// src/test/setup.js
import { expect, afterEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom";
// 每个测试后清理
afterEach(() => {
cleanup();
});
// 全局 Mock
vi.mock("axios", () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
},
}));
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
vi.stubGlobal("localStorage", localStorageMock);
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});Mock 和 Stub
函数 Mock
javascript
// src/test/mocks/userService.test.js
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as userService from "@/services/userService";
import * as userRepository from "@/repositories/userRepository";
import bcrypt from "bcryptjs";
vi.mock("@/repositories/userRepository");
describe("UserService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createUser", () => {
it("应该正确创建用户", async () => {
const userData = {
username: "testuser",
email: "test@example.com",
password: "password123",
};
const hashedPassword = await bcrypt.hash(userData.password, 10);
const mockUser = {
id: "1",
...userData,
password: hashedPassword,
createdAt: new Date(),
};
userRepository.create.mockResolvedValue(mockUser);
const result = await userService.createUser(userData);
expect(result).toEqual(mockUser);
expect(userRepository.create).toHaveBeenCalledWith({
username: userData.username,
email: userData.email,
password: expect.any(String),
});
});
it("用户已存在时应该抛出错误", async () => {
const userData = {
username: "existing",
email: "existing@example.com",
password: "password123",
};
userRepository.findByEmail.mockResolvedValue({
id: "1",
email: userData.email,
});
await expect(userService.createUser(userData)).rejects.toThrow(
"用户已存在"
);
});
it("密码应该被正确哈希", async () => {
const userData = {
username: "test",
email: "test@example.com",
password: "plainpassword",
};
userRepository.create.mockImplementation((data) =>
Promise.resolve({ id: "1", ...data })
);
await userService.createUser(userData);
const calledWith = userRepository.create.mock.calls[0][0];
expect(calledWith.password).not.toBe(userData.password);
expect(calledWith.password).toMatch(/^\$2[aby]?\$\d{1,2}\$/);
});
});
describe("authenticate", () => {
it("正确的凭据应该返回用户", async () => {
const password = "correctpassword";
const hashedPassword = await bcrypt.hash(password, 10);
userRepository.findByEmail.mockResolvedValue({
id: "1",
email: "test@example.com",
password: hashedPassword,
});
const result = await userService.authenticate("test@example.com", password);
expect(result).toEqual({
id: "1",
email: "test@example.com",
});
});
it("错误的密码应该返回 null", async () => {
const hashedPassword = await bcrypt.hash("correctpassword", 10);
userRepository.findByEmail.mockResolvedValue({
id: "1",
email: "test@example.com",
password: hashedPassword,
});
const result = await userService.authenticate(
"test@example.com",
"wrongpassword"
);
expect(result).toBeNull();
});
});
});模块 Mock
javascript
// src/test/mocks/emailService.test.js
import { describe, it, expect, vi, beforeEach } from "vitest";
import { sendWelcomeEmail } from "@/services/emailService";
import * as emailProvider from "@/providers/emailProvider";
vi.mock("@/providers/emailProvider");
describe("EmailService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("sendWelcomeEmail", () => {
it("应该发送欢迎邮件", async () => {
const user = {
email: "test@example.com",
username: "testuser",
};
emailProvider.send.mockResolvedValue({ messageId: "msg-123" });
const result = await sendWelcomeEmail(user);
expect(result).toBe(true);
expect(emailProvider.send).toHaveBeenCalledWith({
to: user.email,
subject: expect.stringContaining("欢迎"),
html: expect.stringContaining(user.username),
});
});
it("发送失败时应该抛出错误", async () => {
const user = { email: "test@example.com", username: "test" };
emailProvider.send.mockRejectedValue(new Error("SMTP 错误"));
await expect(sendWelcomeEmail(user)).rejects.toThrow("邮件发送失败");
});
it("重试次数应该正确设置", async () => {
const user = { email: "test@example.com", username: "test" };
emailProvider.send
.mockRejectedValueOnce(new Error("timeout"))
.mockResolvedValueOnce({ messageId: "msg-123" });
await sendWelcomeEmail(user);
// 第一次失败后会重试
expect(emailProvider.send).toHaveBeenCalledTimes(2);
});
});
});React 组件测试
Hook 测试
javascript
// src/test/hooks/useCounter.test.js
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("应该初始化为 0", () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it("应该支持自定义初始值", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increment 应该增加计数", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrement 应该减少计数", () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it("incrementBy 应该按指定值增加", () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.incrementBy(5);
});
expect(result.current.count).toBe(5);
});
it("reset 应该重置为初始值", () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});组件测试
jsx
// src/test/components/UserCard.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import UserCard from "@/components/UserCard";
const mockUser = {
id: "1",
username: "testuser",
email: "test@example.com",
avatar: "https://example.com/avatar.jpg",
bio: "测试用户简介",
};
describe("UserCard", () => {
it("应该正确显示用户信息", () => {
render(<UserCard user={mockUser} />);
expect(screen.getByText("testuser")).toBeInTheDocument();
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getByText("测试用户简介")).toBeInTheDocument();
});
it("点击编辑按钮应该触发回调", async () => {
const onEdit = vi.fn();
const user = userEvent.setup();
render(<UserCard user={mockUser} onEdit={onEdit} />);
await user.click(screen.getByRole("button", { name: "编辑" }));
expect(onEdit).toHaveBeenCalledWith(mockUser.id);
});
it("头像加载失败应该显示默认头像", () => {
render(<UserCard user={{ ...mockUser, avatar: "invalid-url" }} />);
const img = screen.getByRole("img");
expect(img).toHaveAttribute("alt", "testuser");
});
});测试覆盖率
覆盖率配置
javascript
// vitest.config.js - 添加覆盖率配置
export default defineConfig({
test: {
coverage: {
reporter: ["text", "json", "html", "lcov"],
reportsDirectory: "./coverage",
exclude: [
"node_modules/**",
"*.config.*",
"**/*.d.ts",
"**/*.test.*",
"**/*.spec.*",
],
},
},
});覆盖率报告解读
bash
# 运行测试并生成覆盖率报告
npm run test:coverage
# 查看报告
# Statements: 代码语句执行比例
# Branches: 条件分支执行比例
# Functions: 函数执行比例
# Lines: 代码行执行比例覆盖率检查 CI
yaml
# .github/workflows/test.yml
- name: Check Coverage
run: |
npx vitest run --coverage
npx coverageThreshold@latest \
--config ./coverageThreshold.jsonjson
// coverageThreshold.json
{
"src/**/*.{js,jsx,ts,tsx}": {
"lines": 80,
"functions": 80,
"branches": 75,
"statements": 80
}
}异步测试
async/await 测试
javascript
// src/test/services/asyncService.test.js
import { describe, it, expect, vi } from "vitest";
import { fetchUserData, processUserList } from "@/services/asyncService";
global.fetch = vi.fn();
describe("AsyncService", () => {
describe("fetchUserData", () => {
it("应该正确获取用户数据", async () => {
const mockUser = { id: "1", name: "Test" };
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const result = await fetchUserData("1");
expect(result).toEqual(mockUser);
expect(fetch).toHaveBeenCalledWith("/api/users/1");
});
it("请求失败时应该抛出错误", async () => {
global.fetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(fetchUserData("999")).rejects.toThrow("用户不存在");
});
});
describe("processUserList", () => {
it("应该并发处理用户列表", async () => {
const users = [
{ id: "1", email: "a@test.com" },
{ id: "2", email: "b@test.com" },
{ id: "3", email: "c@test.com" },
];
global.fetch.mockImplementation((url) =>
Promise.resolve({
ok: true,
json: async () => ({ id: url.split("/").pop(), processed: true }),
})
);
const results = await processUserList(users);
expect(results).toHaveLength(3);
expect(results.every((r) => r.processed)).toBe(true);
});
});
});测试最佳实践
- 测试应该快速、独立、可重复
- 每个测试只验证一个行为
- 使用描述性的测试名称
- 及时清理测试数据和 Mock
总结
使用 Claude Code 编写深度单元测试:
- 配置完整的测试环境
- 使用 Mock 和 Stub 隔离依赖
- 测试边界条件和异常情况
- 达到合理的测试覆盖率
- 遵循测试最佳实践