第 25 章:场景六:测试编写
编写测试是开发者永恒的痛点——大家都知道应该写,但总是"没时间"。结果就是:核心业务逻辑只有零星的几个测试,覆盖率在 20% 上下徘徊。重构时心惊胆战,改一行代码怕引发三个 Bug。
这正是 Claude Code 大显身手的场景:它不会偷懒、不会说"这个太简单不用测"、不会因为疲劳而遗漏边界条件。本章通过一个真实的用户服务模块,展示如何用 Claude Code 将测试覆盖率从 20% 提升到 85% 以上——而你只需要用自然语言描述需求。
本章目标:学会用 Claude Code 为现有代码编写单元测试、边界条件测试和集成测试;掌握覆盖率分析→补充用例→再次验证的迭代流程;理解人工审查在 AI 生成测试中的关键作用。
25.1 场景描述
背景
你维护着一个后端项目的用户服务模块 userService.js,它负责用户的注册、登录、资料更新、密码重置等核心功能。模块上线半年了,功能基本稳定,但测试只有最初写 register 函数时顺手写的 3 个用例——覆盖率不到 20%。
// src/services/userService.js(简化版,展示核心结构)
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { db } from '../database';
import { validateEmail, validatePassword } from '../utils/validators';
import { sendWelcomeEmail } from '../utils/email';
export async function register(email, password, name) {
if (!email || !password || !name) {
throw new Error('缺少必填字段');
}
if (!validateEmail(email)) {
throw new Error('邮箱格式不正确');
}
if (!validatePassword(password)) {
throw new Error('密码长度至少为 8 位,且需包含字母和数字');
}
const existing = await db.users.findByEmail(email);
if (existing) {
throw new Error('该邮箱已注册');
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await db.users.create({ email, password: hashedPassword, name });
await sendWelcomeEmail(email, name);
return { id: user.id, email: user.email, name: user.name };
}
export async function login(email, password) {
if (!email || !password) {
throw new Error('邮箱和密码不能为空');
}
const user = await db.users.findByEmail(email);
if (!user) {
throw new Error('用户不存在或密码错误');
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
throw new Error('用户不存在或密码错误');
}
const token = jwt.sign(
{ userId: user.id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
return { token, user: { id: user.id, email: user.email, name: user.name } };
}
export async function updateProfile(userId, updates) {
if (!userId) {
throw new Error('用户 ID 不能为空');
}
if (!updates || Object.keys(updates).length === 0) {
throw new Error('更新内容不能为空');
}
const allowedFields = ['name', 'avatar', 'bio'];
const invalidFields = Object.keys(updates).filter(f => !allowedFields.includes(f));
if (invalidFields.length > 0) {
throw new Error(`不允许更新的字段: ${invalidFields.join(', ')}`);
}
if (updates.name !== undefined) {
if (typeof updates.name !== 'string' || updates.name.trim().length === 0) {
throw new Error('用户名不能为空');
}
if (updates.name.length > 50) {
throw new Error('用户名不能超过 50 个字符');
}
}
const user = await db.users.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
return await db.users.update(userId, updates);
}
export async function getUserProfile(userId) {
if (!userId) {
throw new Error('用户 ID 不能为空');
}
const user = await db.users.findById(userId);
if (!user) {
throw new Error('用户不存在');
}
return { id: user.id, email: user.email, name: user.name, avatar: user.avatar, bio: user.bio };
}现有测试
目前只有一个 userService.test.js,里面只有 3 个测试:
// 现有测试——覆盖率仅 20%
describe('userService', () => {
it('register 成功注册用户', async () => { /* ... */ });
it('register 拒绝重复邮箱', async () => { /* ... */ });
it('login 成功登录', async () => { /* ... */ });
});updateProfile、getUserProfile 完全没有测试;login 只测了成功路径,没有测密码错误、用户不存在等分支;register 没有测邮箱格式错误、密码强度不够等边界情况。
目标
将测试覆盖率从 20% 提升到 85% 以上,覆盖所有公开函数的正常路径、错误路径和关键边界条件。
25.2 单元测试生成
让 Claude 生成全量单元测试
第一步最直接:将源码交给 Claude Code,让它为每个函数生成完整的单元测试。
帮我把 userService.js 里的每个函数都写单元测试,用 vitest 框架。要求:
1. 每个函数覆盖正常路径和所有错误路径
2. 使用 mock 隔离数据库和外部依赖
3. 测试文件放到 src/services/__tests__/userService.test.ts
4. 参考项目中已有的测试风格
源码文件:src/services/userService.jsClaude Code 的执行过程:
- 读取源码:
Read src/services/userService.js - 读取现有测试:
Read src/services/__tests__/userService.test.ts(了解项目测试风格——用什么断言库、mock 方式、describe/it 结构) - 读取 vitest 配置:检查
vitest.config.ts或package.json中的测试配置,确认测试环境 - 分析每个函数:
register(email, password, name)→ 6 个分支(成功 / 缺字段 / 邮箱格式错 / 密码弱 / 邮箱已存在 / bcrypt 失败)login(email, password)→ 4 个分支(成功 / 空字段 / 用户不存在 / 密码错误)updateProfile(userId, updates)→ 7 个分支(成功 / 缺 userId / 空 updates / 非法字段 / 空名字 / 超长名字 / 用户不存在)getUserProfile(userId)→ 3 个分支(成功 / 缺 userId / 用户不存在)
- 生成测试文件:
Write src/services/__tests__/userService.test.ts
Claude 生成的测试代码(核心片段)
// src/services/__tests__/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { register, login, updateProfile, getUserProfile } from '../userService';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { db } from '../../database';
import { validateEmail, validatePassword } from '../../utils/validators';
import { sendWelcomeEmail } from '../../utils/email';
// Mock 外部依赖
vi.mock('../../database');
vi.mock('bcrypt');
vi.mock('jsonwebtoken');
vi.mock('../../utils/validators');
vi.mock('../../utils/email');
describe('register', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(validateEmail).mockReturnValue(true);
vi.mocked(validatePassword).mockReturnValue(true);
vi.mocked(bcrypt.hash).mockResolvedValue('hashed_pw');
vi.mocked(sendWelcomeEmail).mockResolvedValue(undefined);
});
// ✅ 正常路径
it('成功注册新用户', async () => {
vi.mocked(db.users.findByEmail).mockResolvedValue(null);
vi.mocked(db.users.create).mockResolvedValue({
id: 1, email: 'test@example.com', name: 'Test',
});
const result = await register('test@example.com', 'Password123', 'Test');
expect(result).toEqual({ id: 1, email: 'test@example.com', name: 'Test' });
expect(bcrypt.hash).toHaveBeenCalledWith('Password123', 10);
expect(sendWelcomeEmail).toHaveBeenCalledWith('test@example.com', 'Test');
});
// ❌ 错误路径
it('缺少必填字段时抛出错误', async () => {
await expect(register('', 'pw', '')).rejects.toThrow('缺少必填字段');
});
it('邮箱格式不正确时抛出错误', async () => {
vi.mocked(validateEmail).mockReturnValue(false);
await expect(register('bad-email', 'Password123', 'Test'))
.rejects.toThrow('邮箱格式不正确');
});
it('密码强度不够时抛出错误', async () => {
vi.mocked(validatePassword).mockReturnValue(false);
await expect(register('test@example.com', '123', 'Test'))
.rejects.toThrow('密码长度至少为 8 位');
});
it('邮箱已被注册时抛出错误', async () => {
vi.mocked(db.users.findByEmail).mockResolvedValue({ id: 2 });
await expect(register('test@example.com', 'Password123', 'Test'))
.rejects.toThrow('该邮箱已注册');
});
});
describe('login', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('成功登录返回 token 和用户信息', async () => {
const mockUser = { id: 1, email: 'test@example.com', password: 'hashed', name: 'Test' };
vi.mocked(db.users.findByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.compare).mockResolvedValue(true);
vi.mocked(jwt.sign).mockReturnValue('fake-jwt-token');
const result = await login('test@example.com', 'Password123');
expect(result.token).toBe('fake-jwt-token');
expect(result.user).toEqual({ id: 1, email: 'test@example.com', name: 'Test' });
});
it('邮箱或密码为空时抛出错误', async () => {
await expect(login('', 'pw')).rejects.toThrow('邮箱和密码不能为空');
});
it('用户不存在时抛出错误', async () => {
vi.mocked(db.users.findByEmail).mockResolvedValue(null);
await expect(login('notfound@example.com', 'pw'))
.rejects.toThrow('用户不存在或密码错误');
});
it('密码错误时抛出错误', async () => {
vi.mocked(db.users.findByEmail).mockResolvedValue({ id: 1, password: 'hashed' });
vi.mocked(bcrypt.compare).mockResolvedValue(false);
await expect(login('test@example.com', 'wrongpw'))
.rejects.toThrow('用户不存在或密码错误');
});
});
describe('updateProfile', () => {
it('成功更新用户资料', async () => {
vi.mocked(db.users.findById).mockResolvedValue({ id: 1 });
vi.mocked(db.users.update).mockResolvedValue({ id: 1, name: 'NewName', avatar: null, bio: null });
const result = await updateProfile(1, { name: 'NewName' });
expect(result.name).toBe('NewName');
});
it('用户 ID 为空时抛出错误', async () => {
await expect(updateProfile(null, { name: 'Test' }))
.rejects.toThrow('用户 ID 不能为空');
});
it('更新内容为空时抛出错误', async () => {
await expect(updateProfile(1, {}))
.rejects.toThrow('更新内容不能为空');
});
it('包含不允许的字段时抛出错误', async () => {
await expect(updateProfile(1, { email: 'new@example.com' }))
.rejects.toThrow('不允许更新的字段');
});
it('用户不存在时抛出错误', async () => {
vi.mocked(db.users.findById).mockResolvedValue(null);
await expect(updateProfile(1, { name: 'Test' }))
.rejects.toThrow('用户不存在');
});
});
describe('getUserProfile', () => {
it('成功获取用户资料', async () => {
vi.mocked(db.users.findById).mockResolvedValue({
id: 1, email: 'test@example.com', name: 'Test',
avatar: 'avatar.jpg', bio: 'Hello',
});
const result = await getUserProfile(1);
expect(result).toEqual({
id: 1, email: 'test@example.com', name: 'Test',
avatar: 'avatar.jpg', bio: 'Hello',
});
});
it('用户 ID 为空时抛出错误', async () => {
await expect(getUserProfile(null)).rejects.toThrow('用户 ID 不能为空');
});
it('用户不存在时抛出错误', async () => {
vi.mocked(db.users.findById).mockResolvedValue(null);
await expect(getUserProfile(999)).rejects.toThrow('用户不存在');
});
});运行测试,确认通过
Claude 生成后,让它直接运行验证:
运行 vitest 确认新测试全部通过Claude 执行 npx vitest run src/services/__tests__/userService.test.ts,输出类似:
✓ src/services/__tests__/userService.test.ts (18 tests) 342ms
Test Files 1 passed (1)
Tests 18 passed (18)测试从 3 个增长到 18 个,覆盖了所有函数的正常和错误路径。此时覆盖率大约在 55%–60%——因为 updateProfile 中 name 字段的边界条件(空字符串、超长)、register 中 password 为 null 等情况还没有专门的测试用例。
25.3 边界条件测试
让 Claude 发现边界条件盲区
常规路径测试完成后,下一步是让 Claude 系统性地分析每个函数的边界条件:
帮我补充边界条件测试,针对每个函数分析以下情况:
- 空字符串 vs undefined vs null
- 超长输入(如 1000 字符的用户名)
- 特殊字符(SQL 注入尝试、XSS、Unicode 表情)
- 并发调用(同时注册相同邮箱)
- 极端值(userId 为 0、负数、浮点数)Claude 逐一分析每个函数:
register 的边界分析:
email为nullvsundefinedvs''(空字符串)——行为是否一致?password为纯空格' '——应该被拒绝name包含<script>alert('xss')</script>——是否安全?- 并发调用:两个请求同时用同一邮箱注册
login 的边界分析:
password为null——与空字符串的行为应有区别email大小写混用'Test@Example.Com'- 短时间内大量失败登录(安全测试——是否需要限流?)
updateProfile 的边界分析:
name为空字符串''——看代码应该抛出错误,但需要验证name为 51 个字符(刚好超限)name包含 emoji'😀😀😀'updates为nullvsundefined
生成的边界条件测试
Claude 生成以下补充测试:
// 边界条件测试补充
describe('register - 边界条件', () => {
it('email 为 null 时抛出错误', async () => {
await expect(register(null, 'Password123', 'Test'))
.rejects.toThrow('缺少必填字段');
});
it('email 为 undefined 时抛出错误', async () => {
await expect(register(undefined, 'Password123', 'Test'))
.rejects.toThrow('缺少必填字段');
});
it('password 为纯空格时被拒绝', async () => {
vi.mocked(validatePassword).mockReturnValue(false);
await expect(register('test@example.com', ' ', 'Test'))
.rejects.toThrow('密码长度至少为 8 位');
});
it('name 包含 XSS 脚本时正常存储(由前端负责转义)', async () => {
vi.mocked(db.users.findByEmail).mockResolvedValue(null);
vi.mocked(db.users.create).mockResolvedValue({
id: 1, email: 'test@example.com', name: '<script>alert(1)</script>',
});
const result = await register('test@example.com', 'Password123', '<script>alert(1)</script>');
expect(result.name).toBe('<script>alert(1)</script>');
// 注:实际项目中应在 API 层或前端做 XSS 防护
});
it('并发注册相同邮箱时只有一个成功', async () => {
vi.mocked(db.users.findByEmail)
.mockResolvedValueOnce(null) // 第一个请求:邮箱可用
.mockResolvedValueOnce(null); // 第二个请求:邮箱也可用(竞态条件!)
vi.mocked(db.users.create)
.mockResolvedValueOnce({ id: 1, email: 'test@example.com', name: 'A' })
.mockRejectedValueOnce(new Error('Duplicate entry')); // 第二个失败
const [r1, r2] = await Promise.allSettled([
register('test@example.com', 'Password123', 'A'),
register('test@example.com', 'Password123', 'B'),
]);
expect(r1.status).toBe('fulfilled');
expect(r2.status).toBe('rejected');
// ⚠️ 发现潜在问题:如果两个请求都通过了 findByEmail 检查,
// 数据库层面的唯一约束是最后防线。建议在 findByEmail 处加锁。
});
});
describe('updateProfile - 边界条件', () => {
it('name 为空字符串时抛出错误', async () => {
await expect(updateProfile(1, { name: '' }))
.rejects.toThrow('用户名不能为空');
// ✅ 通过:代码中 trim().length === 0 的判断正确处理了空字符串
});
it('name 为纯空格时抛出错误', async () => {
await expect(updateProfile(1, { name: ' ' }))
.rejects.toThrow('用户名不能为空');
});
it('name 恰好 50 个字符时更新成功', async () => {
const maxName = 'a'.repeat(50);
vi.mocked(db.users.findById).mockResolvedValue({ id: 1 });
vi.mocked(db.users.update).mockResolvedValue({ id: 1, name: maxName });
const result = await updateProfile(1, { name: maxName });
expect(result.name.length).toBe(50);
});
it('name 超过 50 个字符时抛出错误', async () => {
const longName = 'a'.repeat(51);
await expect(updateProfile(1, { name: longName }))
.rejects.toThrow('用户名不能超过 50 个字符');
});
it('name 包含 emoji 时正常存储', async () => {
vi.mocked(db.users.findById).mockResolvedValue({ id: 1 });
vi.mocked(db.users.update).mockResolvedValue({ id: 1, name: '😀🎉' });
const result = await updateProfile(1, { name: '😀🎉' });
expect(result.name).toBe('😀🎉');
});
it('userId 为 0 时正常查询(0 是有效数字)', async () => {
vi.mocked(db.users.findById).mockResolvedValue({
id: 0, email: 'root@example.com', name: 'Root',
});
const result = await getUserProfile(0);
expect(result.id).toBe(0);
});
it('userId 为负数时查询失败', async () => {
vi.mocked(db.users.findById).mockResolvedValue(null);
await expect(getUserProfile(-1)).rejects.toThrow('用户不存在');
});
});
describe('login - 边界条件', () => {
it('password 为 null 时抛出错误', async () => {
await expect(login('test@example.com', null))
.rejects.toThrow('邮箱和密码不能为空');
});
});运行边界测试——发现一个 Bug
运行边界条件测试Claude 运行测试后报告:
✓ register - 边界条件 › email 为 null 时抛出错误
✓ register - 边界条件 › email 为 undefined 时抛出错误
✗ register - 边界条件 › name 为 undefined 时不抛错,导致数据库存储了 undefined发现了一个边界 Bug:当 name 为 undefined 时,if (!email || !password || !name) 中的 !undefined 为 true,按理应该进入错误分支。但经过排查,发现是 register('test@example.com', 'Password123') 的调用方式——当参数不传 name 时,name 在函数内部确实是 undefined,但问题出在 sendWelcomeEmail(email, name) 中,name 为 undefined 时邮件服务不会报错但发送的欢迎邮件中用户名为 "undefined"。
这个 Bug 的修复很简单——在函数开头增加显式的类型检查:
在 register 函数中,对 name 参数增加 undefined 检查,确保 name 为 undefined 时抛出明确错误Claude 使用 Edit 工具修改源码:
// 修改前
if (!email || !password || !name) {
throw new Error('缺少必填字段');
}
// 修改后
if (!email || !password) {
throw new Error('缺少必填字段');
}
if (name === undefined || name === null || (typeof name === 'string' && name.trim().length === 0)) {
throw new Error('用户名不能为空');
}这个修复也顺带处理了 name 为空字符串的情况,代码更加健壮。
这就是边界测试的价值:不是证明代码"应该工作",而是发现代码"什么时候会意外失败"。如果只测正常路径和已知错误路径,这类
undefined遗漏的问题可能要等到生产环境收到第一封"尊敬的 undefined 用户"邮件时才会被发现。
25.4 集成测试
编写端到端的业务流程测试
单元测试保证了每个函数独立正确,但真实场景是函数的组合调用。接下来让 Claude 编写集成测试。
帮我写一个集成测试,模拟完整的用户流程:
注册用户 → 登录 → 更新资料 → 验证资料更新
要求:
1. 使用真实的测试数据库(sqlite 内存模式)
2. 每个测试用例之间要重置数据库状态
3. 测试文件:src/services/__tests__/userService.integration.test.ts配置测试数据库
Claude 首先确认项目的数据库配置方式:
项目中数据库连接是怎么管理的?测试中需要使用独立的测试数据库你告诉它:
数据库通过环境变量 DATABASE_URL 控制,测试环境用 sqlite::memory:
帮我配置 vitest 的 setup 文件来初始化测试数据库Claude 会:
- 检查
vitest.config.ts中的setupFiles配置 - 创建或更新
src/test-setup.ts——在测试启动时设置DATABASE_URL=sqlite::memory:并运行数据库迁移 - 添加
globalSetup和teardown来确保测试数据库的隔离性
// src/test-setup.ts
import { beforeAll, afterAll } from 'vitest';
import { db } from './database';
beforeAll(async () => {
// 设置测试数据库
process.env.DATABASE_URL = 'sqlite::memory:';
process.env.JWT_SECRET = 'test-secret-key';
await db.migrate();
});
afterAll(async () => {
await db.close();
});集成测试代码
// src/services/__tests__/userService.integration.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { register, login, updateProfile, getUserProfile } from '../userService';
import { db } from '../../database';
describe('用户服务集成测试', () => {
// 每个测试前清空所有表
beforeEach(async () => {
await db.users.deleteAll();
});
const testUser = {
email: 'alice@example.com',
password: 'SecurePass123',
name: 'Alice',
};
it('完整用户流程:注册 → 登录 → 更新资料 → 验证更新', async () => {
// Step 1: 注册新用户
const registered = await register(testUser.email, testUser.password, testUser.name);
expect(registered.email).toBe(testUser.email);
expect(registered.name).toBe(testUser.name);
expect(registered).not.toHaveProperty('password'); // 密码不应返回
// Step 2: 用注册的账号登录
const loggedIn = await login(testUser.email, testUser.password);
expect(loggedIn.token).toBeTruthy();
expect(loggedIn.user.email).toBe(testUser.email);
// Step 3: 更新用户资料
const updated = await updateProfile(registered.id, {
name: 'Alice Updated',
bio: 'Full-stack developer',
});
expect(updated.name).toBe('Alice Updated');
expect(updated.bio).toBe('Full-stack developer');
// Step 4: 验证资料已更新
const profile = await getUserProfile(registered.id);
expect(profile.name).toBe('Alice Updated');
expect(profile.bio).toBe('Full-stack developer');
});
it('注册后不能重复注册相同邮箱', async () => {
await register(testUser.email, testUser.password, testUser.name);
await expect(
register(testUser.email, 'AnotherPass1', 'Bob')
).rejects.toThrow('该邮箱已注册');
});
it('注册后用错误密码登录失败', async () => {
await register(testUser.email, testUser.password, testUser.name);
await expect(
login(testUser.email, 'WrongPassword1')
).rejects.toThrow('用户不存在或密码错误');
});
it('未注册用户更新资料失败', async () => {
await expect(
updateProfile(99999, { name: 'Hacker' })
).rejects.toThrow('用户不存在');
});
it('并发注册时数据一致性', async () => {
// 10 个并发请求用不同邮箱注册
const tasks = Array.from({ length: 10 }, (_, i) =>
register(`user${i}@example.com`, 'SecurePass123', `User${i}`)
);
const results = await Promise.all(tasks);
expect(results).toHaveLength(10);
// 验证所有用户都被正确创建
for (const user of results) {
expect(user.id).toBeTruthy();
expect(user.email).toBeTruthy();
}
});
});运行集成测试
运行集成测试✓ 用户服务集成测试 › 完整用户流程:注册 → 登录 → 更新资料 → 验证更新
✓ 用户服务集成测试 › 注册后不能重复注册相同邮箱
✓ 用户服务集成测试 › 注册后用错误密码登录失败
✓ 用户服务集成测试 › 未注册用户更新资料失败
✓ 用户服务集成测试 › 并发注册时数据一致性
Test Files 1 passed (1)
Tests 5 passed (5)集成测试全部通过。现在单元测试(含边界条件)加上集成测试,覆盖率已经接近 75%–80%。
集成测试 vs 单元测试:单元测试验证每个函数的行为是否正确(mock 一切外部依赖),集成测试验证函数之间的协作是否正确(使用真实数据库)。两者互补,不可互相替代。单元测试能精准定位问题,集成测试能发现"所有单元单独测都通过但合在一起就炸"的隐蔽 Bug。
25.5 测试覆盖率优化
生成覆盖率报告
测试写得差不多了,但"差不多"是危险的——需要用数据说话。
运行测试覆盖率报告,找出未覆盖的分支Claude 执行:
npx vitest run --coverage输出覆盖率报告:
-----------------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------------|---------|----------|---------|---------|
src/services/userService.js | 78.33 | 63.15 | 92.30 | 80.00 |
-----------------------------|---------|----------|---------|---------|覆盖率维度说明:
| 指标 | 当前值 | 含义 |
|---|---|---|
| Statements(语句) | 78.33% | 78% 的代码行被至少一次测试执行到 |
| Branches(分支) | 63.15% | 仅 63% 的条件分支(if/else、switch case)被覆盖——这是短板 |
| Functions(函数) | 92.30% | 几乎所有函数都有测试调用 |
| Lines(行) | 80.00% | 80% 的可执行行被覆盖 |
分析未覆盖分支
Claude 进一步分析覆盖率报告中的具体未覆盖行:
帮我分析覆盖率报告,列出每个未覆盖的分支,并提出补充用例的建议Claude 的分析结果:
未覆盖分支分析:
1. userService.js:18 — bcrypt.hash 抛出异常时的处理(register 函数)
→ 当前测试 mock 了 bcrypt.hash 永远成功
→ 建议:增加 "bcrypt 哈希失败时抛出错误" 用例
2. userService.js:42 — jwt.sign 失败时的处理(login 函数)
→ 当前测试 mock jwt.sign 永远返回 token
→ 建议:增加 "JWT 签名失败时抛出错误" 用例
3. userService.js:58-60 — updateProfile 中 updates 为 null 的路径
→ 当前覆盖了空对象 {},未覆盖 null
→ 建议:增加 "updates 为 null 时抛出错误" 用例
4. userService.js:65 — 传入了合法字段但值为 undefined
→ 如 { name: undefined }:Object.keys 会包含 'name',但值为 undefined
→ 建议:增加 "{ name: undefined } 时的行为" 用例
5. userService.js:73 — db.users.findById 抛出数据库异常
→ 当前只 mock 了返回 null(用户不存在),未 mock 数据库连接失败
→ 建议:增加 "数据库查询失败时抛出错误" 用例补充遗漏用例
根据分析结果,让 Claude 补充这些针对性测试:
根据覆盖率分析结果,补充未覆盖分支的测试用例// 覆盖率优化补充用例
describe('register - 覆盖率补充', () => {
it('bcrypt 哈希失败时抛出错误', async () => {
vi.mocked(bcrypt.hash).mockRejectedValue(new Error('bcrypt error'));
await expect(register('test@example.com', 'Password123', 'Test'))
.rejects.toThrow('bcrypt error');
});
});
describe('login - 覆盖率补充', () => {
it('JWT 签名失败时抛出错误', async () => {
vi.mocked(db.users.findByEmail).mockResolvedValue({
id: 1, email: 'test@example.com', password: 'hashed', name: 'Test',
});
vi.mocked(bcrypt.compare).mockResolvedValue(true);
vi.mocked(jwt.sign).mockImplementation(() => {
throw new Error('JWT signing failed');
});
await expect(login('test@example.com', 'Password123'))
.rejects.toThrow('JWT signing failed');
});
});
describe('updateProfile - 覆盖率补充', () => {
it('updates 为 null 时抛出错误', async () => {
await expect(updateProfile(1, null))
.rejects.toThrow('更新内容不能为空');
});
it('传入合法字段但值为 undefined 时正确处理', async () => {
// 当前行为:name 为 undefined 时通过 allowedFields 检查但 trim() 调用失败
// 这个测试记录当前行为,之后可以决定是否需要修复
await expect(updateProfile(1, { name: undefined }))
.rejects.toThrow(); // 抛出 TypeError,因为 undefined.trim() 不存在
});
it('数据库查询失败时抛出错误', async () => {
vi.mocked(db.users.findById).mockRejectedValue(new Error('DB connection failed'));
await expect(updateProfile(1, { name: 'Test' }))
.rejects.toThrow('DB connection failed');
});
});最终覆盖率
补充遗漏用例后,再次运行覆盖率报告:
npx vitest run --coverage-----------------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------------|---------|----------|---------|---------|
src/services/userService.js | 95.12 | 88.46 | 100.0 | 95.83 |
-----------------------------|---------|----------|---------|---------|| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| Statements | 78.33% | 95.12% | +16.79% |
| Branches | 63.15% | 88.46% | +25.31% |
| Functions | 92.30% | 100.0% | +7.70% |
| Lines | 80.00% | 95.83% | +15.83% |
从最初的 20%(3 个测试)提升到 ~95%(32 个测试),所有公开函数的每个分支都有了对应测试。未覆盖的 ~5% 主要是一些极端防御性代码(如 process.env.JWT_SECRET 为 undefined 的情况),这些在正常环境中永远不会触发,可以接受不覆盖。
覆盖率迭代流程图
整个覆盖率优化是一个分析 → 补充 → 验证 → 再分析的迭代循环:
25.6 复盘与技巧总结
让 Claude 写测试的优势
1. 速度快得惊人:18 个测试用例,人工写至少需要 2–3 小时(读源码、理解逻辑、写 mock、想边界条件)。Claude 读完源码后,几分钟内就生成了完整且可运行的测试文件。保守估计效率提升 5–10 倍。
2. 不遗漏错误分支:开发者写测试时常犯的错误是只写"Happy Path"——函数正常工作的场景。Claude 会系统性地枚举每个 if/else 分支,包括"邮箱已注册"、"密码哈希失败"、"JWT 签名失败"这些容易被遗忘的路径。
3. 边界条件不会"嫌麻烦":name 刚好 50 个字符能通过、51 个字符被拒绝;email 为 null vs undefined vs 空字符串——这些边界测试写起来琐碎,人脑容易跳过,但 Claude 不会觉得"无聊"。
4. 覆盖率报告的自动分析:Claude 能读取覆盖率报告,定位未覆盖的具体代码行,然后自动生成针对性测试。这比人工一行行对覆盖率报告要高效得多。
注意事项与陷阱
1. Claude 不理解业务逻辑的特殊性
Claude 能为 register 写出覆盖所有分支的测试,但它不知道你的业务规则——比如"VIP 用户注册不需要邮箱验证"、"特定域名(@company.com)自动加入企业工作区"。这些业务特殊性需要你在 Prompt 中明确指出,或者由人工审查时补充对应的测试用例。
❌ "帮我写测试" → Claude 按通用逻辑写
✅ "帮我写测试。注意:@vip.com 域名用户注册时跳过邮箱验证,直接激活账号"2. 测试断言需要人工审查
Claude 生成的断言有时过于"宽容"或过于"严格":
// 过于严格:检查了整个对象结构,加个字段就失败
expect(result).toEqual({
id: 1, email: 'test@example.com', name: 'Test'
});
// 更好的方式:只断言关键字段
expect(result.id).toBeTruthy();
expect(result.email).toBe('test@example.com');// 过于宽松:只要不抛错就算通过
await expect(register(...)).resolves.not.toThrow();
// 更好的方式:同时验证返回值
const result = await register(...);
expect(result).toHaveProperty('id');
expect(result.email).toBe('test@example.com');审查原则:Claude 写测试结构,你审查断言逻辑。结构很少出错,断言是价值的核心。
3. Mock 的正确性需要验证
Claude 可能 mock 了不该 mock 的东西,或者 mock 的行为与真实行为不一致:
// ❌ Mock 掉了被测试函数本身(这个错误 Claude 很少犯,但值得检查)
vi.mock('../userService');
// ⚠️ Mock 返回值与真实类型不匹配
vi.mocked(db.users.findById).mockResolvedValue('not-an-object');
// ✅ 使用真实的接口类型
vi.mocked(db.users.findById).mockResolvedValue({
id: 1, email: 'test@example.com', name: 'Test', password: 'hashed'
});每当 Claude 添加新 mock 时,快速扫一眼 mock 的返回值是否与真实函数签名一致。
4. "测试通过"不代表"测试正确"
绿油油的通过标记可能掩盖问题:
- 测试可能因为 mock 配置错误而永远通过(比如 mock 了被测试的函数本身)
- 测试可能断言了错误的值(期待抛异常但 mock 让它永远不抛)
- 测试可能没有真正执行到目标代码行
验证方法:故意让源码出错,看对应的测试是否失败:
# 临时在 updateProfile 中注释掉 name 检查
# 如果 'name 为空字符串时抛出错误' 测试仍然通过 → mock 配置有问题这叫做"变异测试"(Mutation Testing),是验证测试质量的终极手段。不过日常实践中,审查断言逻辑+检查覆盖率报告已足够。
关键技巧
1. 指定测试框架
Claude 支持几乎所有主流测试框架,但必须明确指定:
| 提示词 | 效果 |
|---|---|
"帮我写测试" | Claude 可能用默认框架(不一定是你项目用的) |
"用 vitest 写测试" | Claude 使用 describe/it/expect/vi.mock |
"用 jest 写测试" | 使用 Jest 的 API(jest.fn() 等) |
"用 pytest 写测试" | 使用 Python 的 pytest 风格 |
2. 提供示例测试让 Claude 模仿风格
如果你的项目有特定的测试约定(如用 test() 而非 it()、特定的 mock 封装、自定义的 test helper),先让 Claude 读一个已有的测试文件,它会在生成新测试时模仿这些约定:
先读一下 src/utils/__tests__/helpers.test.ts 了解项目的测试风格,然后用相同的风格为 userService.js 写测试3. 分步迭代,不要一次生成全部
第 1 轮:"为 register 和 login 写单元测试" → 审查 → 运行
第 2 轮:"为 updateProfile 和 getUserProfile 写单元测试" → 审查 → 运行
第 3 轮:"补充边界条件测试" → 审查 → 运行
第 4 轮:"写集成测试" → 审查 → 运行而不是:
第 1 轮:"写完所有的测试,包括单元测试、边界测试和集成测试" → 30 个测试一口气生成
→ 出问题时很难定位是哪一部分的问题每次一个函数或一个类型(单元/边界/集成),运行通过后再继续。这符合 TDD 的"小步快跑"节奏。
4. 使用 TDD Skill 进行系统化测试开发
如果你想要更严谨的测试驱动开发流程,使用 Superpowers 的 TDD Skill:
/superpowers:test-driven-developmentTDD Skill 会引导 Claude 遵循:写失败测试 → 写最少代码让测试通过 → 重构 → 重复。这比"先写代码再补测试"的方式更能保证代码本身就是可测试的。
5. 覆盖率不是终极目标
85% 和 95% 之间,多出来的 10% 往往需要不成比例的努力。追求 100% 覆盖率通常会导致大量"为了覆盖而覆盖"的无意义测试——比如测试 process.env.JWT_SECRET 为 undefined 时会怎样(这在启动检查中早就被拦截了)。
合理的覆盖率目标:
| 项目类型 | 建议覆盖率 | 理由 |
|---|---|---|
| 核心业务逻辑 | 90%+ | 出问题直接影响收入/用户 |
| 工具/库函数 | 95%+ | 被大量调用,需要高可靠性 |
| UI 组件 | 60–80% | 渲染逻辑的测试投入产出比较低 |
| 配置/常量文件 | 不需要 | 没有逻辑,测试无意义 |
本章小结
本章通过一个真实的用户服务模块,展示了用 Claude Code 将测试覆盖率从 20% 提升到 95% 的完整流程。核心步骤是:生成单元测试(覆盖函数基本逻辑)→ 补充边界条件测试(发现隐蔽 Bug)→ 编写集成测试(验证端到端流程)→ 分析覆盖率报告(定位遗漏分支)→ 迭代补充。
测试编写是 Claude Code 最能发挥效率优势的场景之一。它不会偷懒、不会觉得"这个太简单不用测"、不会疏漏边界条件。但你要记住:Claude 写测试结构,你审查断言逻辑。测试的价值在于断言——断言错了,再高的覆盖率也是空中楼阁。
从下一章开始,我们将进入文档生成的场景——看看 Claude Code 如何帮你把"写文档"这件让人头疼的事变成几秒钟的自动操作。