Skip to content
Published at:

第 25 章:场景六:测试编写

编写测试是开发者永恒的痛点——大家都知道应该写,但总是"没时间"。结果就是:核心业务逻辑只有零星的几个测试,覆盖率在 20% 上下徘徊。重构时心惊胆战,改一行代码怕引发三个 Bug。

这正是 Claude Code 大显身手的场景:它不会偷懒、不会说"这个太简单不用测"、不会因为疲劳而遗漏边界条件。本章通过一个真实的用户服务模块,展示如何用 Claude Code 将测试覆盖率从 20% 提升到 85% 以上——而你只需要用自然语言描述需求。

本章目标:学会用 Claude Code 为现有代码编写单元测试、边界条件测试和集成测试;掌握覆盖率分析→补充用例→再次验证的迭代流程;理解人工审查在 AI 生成测试中的关键作用。

25.1 场景描述

背景

你维护着一个后端项目的用户服务模块 userService.js,它负责用户的注册、登录、资料更新、密码重置等核心功能。模块上线半年了,功能基本稳定,但测试只有最初写 register 函数时顺手写的 3 个用例——覆盖率不到 20%。

javascript
// 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 个测试:

javascript
// 现有测试——覆盖率仅 20%
describe('userService', () => {
  it('register 成功注册用户', async () => { /* ... */ });
  it('register 拒绝重复邮箱', async () => { /* ... */ });
  it('login 成功登录', async () => { /* ... */ });
});

updateProfilegetUserProfile 完全没有测试;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.js

Claude Code 的执行过程:

  1. 读取源码Read src/services/userService.js
  2. 读取现有测试Read src/services/__tests__/userService.test.ts(了解项目测试风格——用什么断言库、mock 方式、describe/it 结构)
  3. 读取 vitest 配置:检查 vitest.config.tspackage.json 中的测试配置,确认测试环境
  4. 分析每个函数
    • register(email, password, name) → 6 个分支(成功 / 缺字段 / 邮箱格式错 / 密码弱 / 邮箱已存在 / bcrypt 失败)
    • login(email, password) → 4 个分支(成功 / 空字段 / 用户不存在 / 密码错误)
    • updateProfile(userId, updates) → 7 个分支(成功 / 缺 userId / 空 updates / 非法字段 / 空名字 / 超长名字 / 用户不存在)
    • getUserProfile(userId) → 3 个分支(成功 / 缺 userId / 用户不存在)
  5. 生成测试文件Write src/services/__tests__/userService.test.ts

Claude 生成的测试代码(核心片段)

typescript
// 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%——因为 updateProfilename 字段的边界条件(空字符串、超长)、registerpasswordnull 等情况还没有专门的测试用例。

25.3 边界条件测试

让 Claude 发现边界条件盲区

常规路径测试完成后,下一步是让 Claude 系统性地分析每个函数的边界条件:

帮我补充边界条件测试,针对每个函数分析以下情况:
- 空字符串 vs undefined vs null
- 超长输入(如 1000 字符的用户名)
- 特殊字符(SQL 注入尝试、XSS、Unicode 表情)
- 并发调用(同时注册相同邮箱)
- 极端值(userId 为 0、负数、浮点数)

Claude 逐一分析每个函数:

register 的边界分析

  • emailnull vs undefined vs ''(空字符串)——行为是否一致?
  • password 为纯空格 ' '——应该被拒绝
  • name 包含 <script>alert('xss')</script>——是否安全?
  • 并发调用:两个请求同时用同一邮箱注册

login 的边界分析

  • passwordnull——与空字符串的行为应有区别
  • email 大小写混用 'Test@Example.Com'
  • 短时间内大量失败登录(安全测试——是否需要限流?)

updateProfile 的边界分析

  • name 为空字符串 ''——看代码应该抛出错误,但需要验证
  • name 为 51 个字符(刚好超限)
  • name 包含 emoji '😀😀😀'
  • updatesnull vs undefined

生成的边界条件测试

Claude 生成以下补充测试:

typescript
// 边界条件测试补充
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:当 nameundefined 时,if (!email || !password || !name) 中的 !undefinedtrue,按理应该进入错误分支。但经过排查,发现是 register('test@example.com', 'Password123') 的调用方式——当参数不传 name 时,name 在函数内部确实是 undefined,但问题出在 sendWelcomeEmail(email, name) 中,nameundefined 时邮件服务不会报错但发送的欢迎邮件中用户名为 "undefined"

这个 Bug 的修复很简单——在函数开头增加显式的类型检查:

在 register 函数中,对 name 参数增加 undefined 检查,确保 name 为 undefined 时抛出明确错误

Claude 使用 Edit 工具修改源码:

javascript
// 修改前
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 会:

  1. 检查 vitest.config.ts 中的 setupFiles 配置
  2. 创建或更新 src/test-setup.ts——在测试启动时设置 DATABASE_URL=sqlite::memory: 并运行数据库迁移
  3. 添加 globalSetupteardown 来确保测试数据库的隔离性
typescript
// 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();
});

集成测试代码

typescript
// 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 执行:

bash
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 补充这些针对性测试:

根据覆盖率分析结果,补充未覆盖分支的测试用例
typescript
// 覆盖率优化补充用例

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');
  });
});

最终覆盖率

补充遗漏用例后,再次运行覆盖率报告:

bash
npx vitest run --coverage
-----------------------------|---------|----------|---------|---------|
File                         | % Stmts | % Branch | % Funcs | % Lines |
-----------------------------|---------|----------|---------|---------|
 src/services/userService.js |   95.12 |    88.46 |   100.0 |   95.83 |
-----------------------------|---------|----------|---------|---------|
指标优化前优化后提升
Statements78.33%95.12%+16.79%
Branches63.15%88.46%+25.31%
Functions92.30%100.0%+7.70%
Lines80.00%95.83%+15.83%

从最初的 20%(3 个测试)提升到 ~95%(32 个测试),所有公开函数的每个分支都有了对应测试。未覆盖的 ~5% 主要是一些极端防御性代码(如 process.env.JWT_SECRETundefined 的情况),这些在正常环境中永远不会触发,可以接受不覆盖。

覆盖率迭代流程图

整个覆盖率优化是一个分析 → 补充 → 验证 → 再分析的迭代循环:

flowchart TD A["初始测试<br/>覆盖率 20%(3 个用例)"] --> B["Step 1: 生成单元测试<br/>覆盖率 → 55%"] B --> C["Step 2: 补充边界条件测试<br/>覆盖率 → 75%,发现 1 个 Bug"] C --> D["Step 3: 编写集成测试<br/>覆盖率 → 80%"] D --> E["Step 4: 覆盖率报告分析<br/>定位未覆盖分支"] E --> F{"覆盖率 ≥ 85%?"} F -->|否| G["补充遗漏用例"] G --> E F -->|是| H["✅ 完成<br/>最终覆盖率 95%"] style A fill:#ffcdd2 style H fill:#c8e6c9 style E fill:#fff9c4 style G fill:#fff9c4

25.6 复盘与技巧总结

让 Claude 写测试的优势

1. 速度快得惊人:18 个测试用例,人工写至少需要 2–3 小时(读源码、理解逻辑、写 mock、想边界条件)。Claude 读完源码后,几分钟内就生成了完整且可运行的测试文件。保守估计效率提升 5–10 倍

2. 不遗漏错误分支:开发者写测试时常犯的错误是只写"Happy Path"——函数正常工作的场景。Claude 会系统性地枚举每个 if/else 分支,包括"邮箱已注册"、"密码哈希失败"、"JWT 签名失败"这些容易被遗忘的路径。

3. 边界条件不会"嫌麻烦"name 刚好 50 个字符能通过、51 个字符被拒绝;emailnull vs undefined vs 空字符串——这些边界测试写起来琐碎,人脑容易跳过,但 Claude 不会觉得"无聊"。

4. 覆盖率报告的自动分析:Claude 能读取覆盖率报告,定位未覆盖的具体代码行,然后自动生成针对性测试。这比人工一行行对覆盖率报告要高效得多。

注意事项与陷阱

1. Claude 不理解业务逻辑的特殊性

Claude 能为 register 写出覆盖所有分支的测试,但它不知道你的业务规则——比如"VIP 用户注册不需要邮箱验证"、"特定域名(@company.com)自动加入企业工作区"。这些业务特殊性需要你在 Prompt 中明确指出,或者由人工审查时补充对应的测试用例。

❌ "帮我写测试" → Claude 按通用逻辑写
✅ "帮我写测试。注意:@vip.com 域名用户注册时跳过邮箱验证,直接激活账号"

2. 测试断言需要人工审查

Claude 生成的断言有时过于"宽容"或过于"严格":

typescript
// 过于严格:检查了整个对象结构,加个字段就失败
expect(result).toEqual({
  id: 1, email: 'test@example.com', name: 'Test'
});

// 更好的方式:只断言关键字段
expect(result.id).toBeTruthy();
expect(result.email).toBe('test@example.com');
typescript
// 过于宽松:只要不抛错就算通过
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 的行为与真实行为不一致:

typescript
// ❌ 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 让它永远不抛)
  • 测试可能没有真正执行到目标代码行

验证方法:故意让源码出错,看对应的测试是否失败:

bash
# 临时在 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-development

TDD Skill 会引导 Claude 遵循:写失败测试 → 写最少代码让测试通过 → 重构 → 重复。这比"先写代码再补测试"的方式更能保证代码本身就是可测试的。

5. 覆盖率不是终极目标

85% 和 95% 之间,多出来的 10% 往往需要不成比例的努力。追求 100% 覆盖率通常会导致大量"为了覆盖而覆盖"的无意义测试——比如测试 process.env.JWT_SECRETundefined 时会怎样(这在启动检查中早就被拦截了)。

合理的覆盖率目标:

项目类型建议覆盖率理由
核心业务逻辑90%+出问题直接影响收入/用户
工具/库函数95%+被大量调用,需要高可靠性
UI 组件60–80%渲染逻辑的测试投入产出比较低
配置/常量文件不需要没有逻辑,测试无意义

本章小结

本章通过一个真实的用户服务模块,展示了用 Claude Code 将测试覆盖率从 20% 提升到 95% 的完整流程。核心步骤是:生成单元测试(覆盖函数基本逻辑)→ 补充边界条件测试(发现隐蔽 Bug)→ 编写集成测试(验证端到端流程)→ 分析覆盖率报告(定位遗漏分支)→ 迭代补充

测试编写是 Claude Code 最能发挥效率优势的场景之一。它不会偷懒、不会觉得"这个太简单不用测"、不会疏漏边界条件。但你要记住:Claude 写测试结构,你审查断言逻辑。测试的价值在于断言——断言错了,再高的覆盖率也是空中楼阁。

从下一章开始,我们将进入文档生成的场景——看看 Claude Code 如何帮你把"写文档"这件让人头疼的事变成几秒钟的自动操作。