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 runStryker 会自动生成数百个变异,运行你的测试套件,报告"变异存活率"——存活率越低,测试越强健。
方法二:边界值分析
对任何有数值比较的函数,至少测试这些点:
临界值 - 1, 临界值, 临界值 + 1typescript
// 折扣规则:消费满 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 生成的测试需要人工验证这一点。核心检查:改坏实现代码,测试是否跟着失败? 如果不失败,测试没有价值。