Skip to content

AI 生成测试的质量把控

为什么 AI 生成的测试需要专门审查

AI 生成代码很快,但快并不等于好。测试代码有一个特别危险的失效模式:测试通过,但什么都没测到。这比没有测试更糟糕,因为它给了你虚假的安全感。

以下是 AI 生成测试时最常见的质量问题。


常见问题一览

1. 假阳性测试(永远通过的测试)

typescript
// 有问题的测试
it('应该返回用户数据', async () => {
  const result = await getUserById('123')
  expect(result).toBeTruthy()  // 只要不是 null/undefined/0/'' 就通过
})

// 正确的测试
it('应该返回正确的用户数据', async () => {
  const result = await getUserById('123')
  expect(result).toEqual({
    id: '123',
    name: 'Alice',
    email: 'alice@example.com',
  })
})

识别标志: 断言只检查值存在,不检查值的内容。


2. 测试实现而非行为

typescript
// 有问题:测试内部实现细节
it('应该调用 repository.findById', async () => {
  const spy = vi.spyOn(userRepository, 'findById')
  await getUserById('123')
  expect(spy).toHaveBeenCalledWith('123')  // 这测的是"怎么做",不是"做什么"
})

// 正确:测试可观察的行为
it('应该返回指定 id 的用户', async () => {
  const user = await getUserById('123')
  expect(user.id).toBe('123')
})

识别标志: 大量使用 spy/mock 的 toHaveBeenCalled,测试的是方法调用而不是结果。


3. 覆盖不完整

AI 倾向于只测"快乐路径",遗漏边界情况和错误路径:

typescript
// AI 常见输出:只测正常情况
describe('divide', () => {
  it('应该正确除法', () => {
    expect(divide(10, 2)).toBe(5)
  })
})

// 完整的测试还应包括:
describe('divide', () => {
  it('应该正确除法', () => {
    expect(divide(10, 2)).toBe(5)
  })

  it('除以零应该抛出错误', () => {
    expect(() => divide(10, 0)).toThrow('不能除以零')
  })

  it('负数除法应该返回负数', () => {
    expect(divide(-10, 2)).toBe(-5)
    expect(divide(10, -2)).toBe(-5)
  })

  it('结果不整除时应该返回浮点数', () => {
    expect(divide(10, 3)).toBeCloseTo(3.333)
  })
})

4. Mock 过度使用

typescript
// 有问题:mock 了所有依赖,测试变成了"验证我写的 mock 返回我写的值"
it('应该发送欢迎邮件', async () => {
  vi.mock('./emailService', () => ({
    sendEmail: vi.fn().mockResolvedValue({ success: true })
  }))
  vi.mock('./userRepository', () => ({
    findById: vi.fn().mockResolvedValue({ email: 'user@example.com', name: 'Alice' })
  }))

  const result = await sendWelcomeEmail('user-1')
  expect(result.success).toBe(true)  // 当然成功,你 mock 了返回值
})

过度 mock 的测试在任何实现下都会通过,包括完全错误的实现。

原则: 只 mock 真正的外部依赖(网络请求、数据库、文件系统、时间),不 mock 被测模块内部的函数。


如何验证测试有效性

方法一:变异测试(Mutation Testing)

变异测试的原理:故意引入 bug,看测试是否能捕获。如果你改了实现代码,测试仍然通过,说明测试没在测那个逻辑。

手动变异测试示例:

typescript
// 原实现
export function isEligibleForDiscount(age: number, memberYears: number): boolean {
  return age >= 18 && memberYears >= 2
}

// 变异 1:改变比较运算符
// return age > 18 && memberYears >= 2  (把 >= 改成 >)
// 如果测试里有 age=18 的 case,应该失败

// 变异 2:改变逻辑运算符
// return age >= 18 || memberYears >= 2  (把 && 改成 ||)
// 应该导致多个测试失败

// 变异 3:删除一个条件
// return age >= 18  (删掉 memberYears 条件)
// 对 memberYears < 2 的 case 应该失败

工具推荐: Stryker Mutator,支持 JavaScript/TypeScript:

bash
npx stryker run

Stryker 会自动生成数百个变异,运行你的测试套件,报告"变异存活率"——存活率越低,测试越强健。


方法二:边界值分析

对任何有数值比较的函数,至少测试这些点:

临界值 - 1, 临界值, 临界值 + 1
typescript
// 折扣规则:消费满 200 打折
describe('银卡折扣边界测试', () => {
  it('199 元:不满足条件,无折扣', () => {
    expect(calculateDiscount(199, 'silver')).toBe(199)
  })

  it('200 元:恰好满足条件,打折', () => {
    expect(calculateDiscount(200, 'silver')).toBe(190)
  })

  it('201 元:超过临界值,打折', () => {
    expect(calculateDiscount(201, 'silver')).toBeCloseTo(190.95)
  })
})

测试代码审查清单

在接受 AI 生成的测试之前,逐项检查:

断言质量

  • [ ] 每个断言都在验证具体的业务规则,而不是"有值"
  • [ ] 使用精确匹配(toBe, toEqual)而不是宽泛匹配(toBeTruthy, toBeDefined
  • [ ] 数值比较有具体期望值,而不是"大于0"之类
  • [ ] 错误断言检查了错误消息内容,而不只是"抛出了错误"

覆盖完整性

  • [ ] 正常路径(至少一个典型用例)
  • [ ] 边界条件(临界值-1、临界值、临界值+1)
  • [ ] 错误路径(非法输入、失败场景)
  • [ ] 空值/零值/负值(根据业务情况)

Mock 合理性

  • [ ] Mock 的是真实外部依赖,不是被测逻辑的内部依赖
  • [ ] Mock 返回值是真实可能的值(不是随便编的)
  • [ ] 没有 mock 被测函数本身

测试独立性

  • [ ] 每个测试用例可以独立运行,不依赖其他测试的执行顺序
  • [ ] beforeEach 中充分重置状态
  • [ ] 没有测试之间共享的可变状态

测试描述

  • [ ] 描述说明了"在什么情况下,期望什么结果"
  • [ ] 失败时的错误信息能帮助定位问题

实用技巧:让 AI 自我审查

生成测试后,可以追加这样的提示:

请审查你刚才生成的测试代码,检查:
1. 是否有永远通过的断言?
2. 是否遗漏了错误路径?
3. 是否有边界条件没有覆盖?
4. Mock 是否合理?

如果发现问题,请修复。

AI 通常能发现并修正自己生成的测试中的明显问题,特别是遗漏的边界情况。


总结

好的测试是"有破坏力的"——当代码出错时,它会失败。AI 生成的测试需要人工验证这一点。核心检查:改坏实现代码,测试是否跟着失败? 如果不失败,测试没有价值。