AI 生成测试,重点不是数量而是命中风险
AI 生成测试的问题出在哪
ACM 2025 年一项针对 LLM 生成测试的质量评估发现:大多数 LLM 生成的测试套件至少包含一个错误或 test smell,断言错误最为常见1。这是当前代码生成范式的系统性短板——AI 擅长模仿测试的结构和语法,却不擅长判断哪些测试值得写。
后续研究进一步印证了这一判断。arXiv 2026 年 3 月的实验表明,随着软件演化,LLM 生成的测试会出现覆盖率下降、遗漏回归的情况2。Applitools 2026 年测试策略报告将问题归纳为信噪比——昂贵的噪声淹没了有价值的信号3。有研究者发现,LLM 生成的测试编译时看起来没问题,但会「幻觉出不存在于函数中的规则」,且倾向于聚集在正常路径4。
一句话:AI 生成测试的问题出在精度,不在数量。
理论基础:测试金字塔、风险驱动与契约测试
要理解「准」的标准从何而来,我先回到三个测试工程的基本理论。
测试金字塔 2.0
测试金字塔的底层逻辑是反馈成本与故障定位精度的平衡——单元测试告诉你哪个函数坏了,E2E 测试告诉你用户还能不能下单。PMC 2025 年的《Test Pyramid 2.0》提出 AI 应嵌入金字塔每一层5,但同时警告:覆盖率不等于质量——不配合风险分层,高覆盖率反而制造虚假安全感。
风险驱动测试
Risk-Based Testing(RBT)是测试优先级决策的核心框架6。核心流程:明确范围 → 映射用户流和依赖 → 识别威胁和失效影响 → 数值评分量化风险 → 按风险等级分配测试深度 → 聚焦高影响路径自动化 → 每 Sprint 更新风险清单。
风险评分公式:Impact × max(复杂度, 历史缺陷率),每项 1-5 分。机票发现引擎(最高严重度 + 最高复杂度)得分 25,乘客值机页面(中等严重度 + 低复杂度)得分 6。测试预算应该按这个分数分配。
契约测试
契约测试验证的是服务间接口——消费方定义期望,提供方保证履约,不需要启动完整环境7。在 AI 代码生成场景下,AI 可能随意修改内部实现,但只要公共契约不变,消费方就不受影响。契约测试守的就是这个「不变量」。
测试优先级决策流程
在实际项目中,我使用以下流程判断 AI 应该为谁生成测试:
四个完整案例:高价值 vs 低价值测试对比
案例一:价格计算函数
被测代码:
function calculateDiscount(items: CartItem[], coupon: Coupon | null, memberLevel: string): number {
if (items.length === 0) return 0
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
let discount = 0
if (subtotal >= 200) discount += subtotal * 0.1 // 满折
if (subtotal >= 500) discount += subtotal * 0.05 // 叠加
if (coupon) {
if (coupon.type === 'fixed') discount += coupon.value
else if (coupon.type === 'percent') discount += (subtotal - discount) * (coupon.value / 100)
}
const memberRates: Record<string, number> = { gold: 0.05, platinum: 0.1 }
discount += subtotal * (memberRates[memberLevel] ?? 0)
return Math.min(discount, subtotal * 0.6) // 最大折扣不超过 60%
}低价值测试(AI 默认生成):
// ❌ 只测正常路径,断言实现细节
describe('calculateDiscount', () => {
it('should calculate discount for a single item', () => {
const items = [{ price: 100, quantity: 1, name: 'T-shirt' }]
const result = calculateDiscount(items, null, 'gold')
expect(result).toBe(15) // 15 怎么来的?读者自己算
})
it('should apply fixed coupon', () => {
const items = [{ price: 100, quantity: 1, name: 'Book' }]
expect(calculateDiscount(items, { type: 'fixed', value: 10 }, 'standard')).toBe(10)
})
})问题: 只测正常路径,没有叠加规则、边界值;断言魔法数字 toBe(15) 读者看不懂;满折阶梯交叉(200 vs 500)和 60% 上限从未被测到;非法输入(不存在的会员等级、负数优惠券)完全缺失。
高价值测试:
// ✅ 覆盖契约、边界、失败路径
describe('calculateDiscount', () => {
describe('满折阶梯边界', () => {
it('消费 199 不触发满折', () => {
expect(calculateDiscount([{ price: 199, quantity: 1 }], null, 'standard')).toBe(0)
})
it('消费 200 触发 10%', () => {
expect(calculateDiscount([{ price: 200, quantity: 1 }], null, 'standard')).toBe(20)
})
it('消费 500 叠加 10% + 5%', () => {
expect(calculateDiscount([{ price: 500, quantity: 1 }], null, 'standard')).toBe(75)
})
})
describe('优惠券叠加 + 上限', () => {
it('满折 + 固定优惠券触发 60% 上限', () => {
const coupon = { type: 'fixed', value: 300 }
// 满折 75 + 固定 300 = 375,但上限 500×0.6 = 300
expect(calculateDiscount([{ price: 500, quantity: 1 }], coupon, 'standard')).toBe(300)
})
})
describe('失败路径', () => {
it('不存在的会员等级视为 standard', () => {
expect(calculateDiscount([{ price: 100, quantity: 1 }], null, 'diamond')).toBe(0)
})
it('负数优惠券不会导致负价格', () => {
const result = calculateDiscount([{ price: 50, quantity: 1 }], { type: 'fixed', value: -100 }, 'standard')
expect(result).toBeLessThanOrEqual(50)
})
})
})对比要点: 高价值测试测的是读者关心的场景——边界、叠加、上限保护、异常输入——而非最容易写的场景。
案例二:API 错误映射
被测代码:
async function handleGetArticle(id: string, repo: ArticleRepo): Promise<ArticleDTO> {
const article = await repo.findById(id)
if (!article) throw new NotFoundError(`Article ${id} not found`)
if (article.status === 'draft') throw new ForbiddenError('Draft articles are not accessible')
return toDTO(article)
}低价值测试:
// ❌ 测了「能找到文章」,但风险在错误路径
describe('handleGetArticle', () => {
it('should return article when found', async () => {
const repo = { findById: vi.fn().mockResolvedValue({ id: '1', title: 'Test', status: 'published' }) }
const result = await handleGetArticle('1', repo as any)
expect(result.title).toBe('Test')
})
it('should call repo.findById with correct id', async () => {
const repo = { findById: vi.fn().mockResolvedValue({ id: 'abc', status: 'published' }) }
await handleGetArticle('abc', repo as any)
expect(repo.findById).toHaveBeenCalledWith('abc') // 耦合实现细节
})
})问题: 第二个测试把内部实现(调用了 repo.findById)当公共契约——未来换成 repo.findOne({ id }) 测试就挂了,但行为没变。这种测试让维护者害怕改代码。
高价值测试:
// ✅ 覆盖错误映射契约和状态守卫
describe('handleGetArticle', () => {
it('文章不存在时抛出 NotFoundError', async () => {
const repo = { findById: vi.fn().mockResolvedValue(null) }
await expect(handleGetArticle('404', repo as any)).rejects.toThrow(NotFoundError)
})
it('草稿状态抛出 ForbiddenError', async () => {
const repo = { findById: vi.fn().mockResolvedValue({ id: '1', status: 'draft' }) }
await expect(handleGetArticle('1', repo as any)).rejects.toThrow(ForbiddenError)
})
it('已发布文章正常返回 DTO,不暴露内部字段', async () => {
const article = { id: '1', title: 'Test', status: 'published', internalTags: ['debug'] }
const repo = { findById: vi.fn().mockResolvedValue(article) }
const result = await handleGetArticle('1', repo as any)
expect(result).not.toHaveProperty('internalTags')
})
})案例三:权限检查
被测代码:
function checkPermission(user: User, action: Action, resource: Resource): boolean {
if (user.role === 'admin') return true
if (user.role === 'editor') {
if (action === 'delete' && resource.type === 'article') return false
return true
}
if (user.role === 'viewer') {
return action === 'read' && resource.type === 'article'
}
return false
}低价值测试:
// ❌ 只测 admin 通过,没有测各角色的权限边界
describe('checkPermission', () => {
it('admin can do everything', () => {
const admin = { role: 'admin' }
expect(checkPermission(admin, 'delete', { type: 'article' })).toBe(true)
expect(checkPermission(admin, 'read', { type: 'user' })).toBe(true)
})
it('unknown role returns false', () => {
const user = { role: 'unknown' }
expect(checkPermission(user, 'read', { type: 'article' })).toBe(false)
})
})高价值测试:
// ✅ 覆盖每个角色的权限边界和关键否定路径
describe('checkPermission', () => {
const article = { type: 'article' } as Resource
const userRes = { type: 'user' } as Resource
it('admin 对任何资源有全部权限', () => {
expect(checkPermission({ role: 'admin' } as User, 'delete', article)).toBe(true)
expect(checkPermission({ role: 'admin' } as User, 'delete', userRes)).toBe(true)
})
it('editor 可编辑文章但不能删除(关键否定路径)', () => {
const editor = { role: 'editor' } as User
expect(checkPermission(editor, 'edit', article)).toBe(true)
expect(checkPermission(editor, 'delete', article)).toBe(false)
expect(checkPermission(editor, 'delete', userRes)).toBe(true) // 非文章允许删除
})
it('viewer 只能读文章,不能读用户资源或编辑', () => {
const viewer = { role: 'viewer' } as User
expect(checkPermission(viewer, 'read', article)).toBe(true)
expect(checkPermission(viewer, 'read', userRes)).toBe(false)
expect(checkPermission(viewer, 'edit', article)).toBe(false)
})
it('未知角色一律拒绝', () => {
expect(checkPermission({ role: 'unknown' } as User, 'read', article)).toBe(false)
})
})案例四:订单状态流转
被测代码:
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled'
const validTransitions: Record<OrderStatus, OrderStatus[]> = {
pending: ['paid', 'cancelled'], paid: ['shipped', 'cancelled'],
shipped: ['delivered'], delivered: [], cancelled: [],
}
function transitionOrder(current: OrderStatus, next: OrderStatus): OrderStatus {
if (!validTransitions[current]?.includes(next))
throw new InvalidTransitionError(`Cannot transition from ${current} to ${next}`)
return next
}低价值测试:
// ❌ 只测正常流转,遗漏非法跳转
describe('transitionOrder', () => {
it('pending → paid', () => { expect(transitionOrder('pending', 'paid')).toBe('paid') })
it('paid → shipped', () => { expect(transitionOrder('paid', 'shipped')).toBe('shipped') })
it('shipped → delivered', () => { expect(transitionOrder('shipped', 'delivered')).toBe('delivered') })
})高价值测试:
// ✅ 覆盖非法跳转、终态保护、撤销路径
describe('transitionOrder', () => {
it.each([
['pending', 'paid'], ['pending', 'cancelled'],
['paid', 'shipped'], ['paid', 'cancelled'], ['shipped', 'delivered'],
] as const)('允许 %s → %s', (from, to) => {
expect(transitionOrder(from, to)).toBe(to)
})
it.each([
['pending', 'shipped'], // 跳过 paid
['pending', 'delivered'], // 跨三步
['delivered', 'pending'], // 终态不可回退
['cancelled', 'paid'], // 取消不可恢复
['shipped', 'cancelled'], // 已发货不可取消
] as const)('拒绝 %s → %s', (from, to) => {
expect(() => transitionOrder(from, to)).toThrow(InvalidTransitionError)
})
})测试优先级矩阵
| 优先级 | 测试对象 | 特征 | AI 生成策略 |
|---|---|---|---|
| P0 | 纯函数、API 契约、权限模型 | 输入输出确定、分支多、回归影响大 | 直接生成,覆盖所有分支和边界 |
| P1 | 状态流转、失败路径、外部依赖 | 有明确规则、涉及错误处理 | 列出所有非法路径,让 AI 补测试 |
| P2 | 业务规则排序、数据转换 | 规则可能变化 | 只测边界和降级 |
| 不补 | 展示组件、营销页面 | 纯视觉、无业务逻辑 | 视觉回归或浏览器验收替代 |
好测试 vs 坏测试:关键差异
| 维度 | 低价值测试 | 高价值测试 |
|---|---|---|
| 测试目标 | 证明代码能跑 | 证明代码会在坏掉时失败 |
| 断言方式 | 魔法数字 toBe(15) | 可解释的计算 |
| 实现耦合 | toHaveBeenCalledWith | 只测公共行为(输入 → 输出) |
| 维护成本 | 重构就挂 | 重构不挂,行为变化才挂 |
让 Agent 先写测试命题
我在实践中最核心的建议:先让 AI 写测试命题,再写测试代码。 测试命题是一个结构化风险清单——可能破坏什么、已有覆盖、需要补什么、不适合自动化的——四个问题理清后再生成测试。
测试命题模板
每个命题包含:改动描述(一句话)→ 可能破坏的行为(全部列出)→ 已有测试(避免重复)→ 需要新增的测试(按优先级排序)→ 不适合自动化的(人工验收)。
实际 Prompt 示例
我要修改 calculateDiscount 函数,增加满折 + 优惠券 + 会员折扣的叠加逻辑。
请先输出测试命题:
1. 列出这次改动可能破坏的所有行为
2. 哪些行为已有测试(当前有 2 个测试覆盖单商品正常路径)
3. 哪些行为需要新增测试,按优先级排序
4. 哪些场景不适合单元测试
命题通过后,再按 P0 > P1 > P2 的顺序生成测试代码。
每个测试的 it 描述必须说明「为什么测这个场景」,而非「测了什么」。
测试质量检查清单
必须通过
- 破坏性测试:把核心逻辑改错(如
>=改>),至少一个测试会失败 - 边界覆盖:所有
if分支的边界值都有测试 - 失败路径:至少一个测试覆盖错误处理
- 无实现耦合:不包含
toHaveBeenCalledWith
红旗警告
- 🔴 测试数量超过 20 个但全是正常路径
- 🔴 断言中出现无法解释的魔法数字
- 🔴 重构函数名后测试失败(耦合了实现细节)
验证测试本身:破坏性测试法
最有效的验证方法是破坏性测试——故意把被测代码改错,看测试能否发现。用 Stryker 等突变测试工具可自动执行(npx stryker run)。
具体操作:把 >= 改成 >、+ 改成 -、return 0 改成 return -1、删除一个 if 分支。如果这些改动不能让任何测试失败,说明测试覆盖的是「已经能跑的部分」,高风险场景没有被测到。
Applitools 2026 年报告指出,LLM 生成的测试「不探索变异体」——不测试能发现 off-by-one 错误的边界条件3。破坏性测试正是弥补这个缺陷的手段。
结论:AI 生成测试的三条军规
第一条:先识别风险,再生成测试。 先让 AI 输出测试命题——可能破坏什么、已有覆盖、需要补什么、不适合自动化的。三个问题比三十个测试更有价值。
第二条:按优先级分配测试预算。 纯函数、API 契约、权限模型是 P0,状态流转和失败路径是 P1,展示组件不补单测。
第三条:验证测试本身。 写完测试做破坏性测试——把核心逻辑改错,看测试是否失败。改错了还能通过的测试就是噪声。
有价值的 AI 生成测试,应当让未来维护者更敢改代码。下次让 AI 补测试时,先花两分钟写清楚 Prompt——告诉它风险在哪里,比告诉它「写测试」有效得多。
参考资料:
Footnotes
-
Quality Assessment of Python Tests Generated by Large Language Models, ACM, 2025 ↩
-
Evaluating LLM-Based Test Generation Under Software Evolution, arXiv, 2026 ↩
-
AI Testing in 2026: Why Signal, Trust, and Intentional Choices Matter, Applitools, 2026 ↩ ↩2
-
Unmasking the Flaws: Why AI-Generated Unit Tests Fall Short in Real Codebases, Medium, 2025 ↩
-
The Test Pyramid 2.0: AI-assisted testing across the pyramid, PMC, 2025 ↩
-
A Practical Approach to Risk-Based Testing in 2026, testingmind, 2026 ↩
-
The Practical Test Pyramid, Martin Fowler, 2018 ↩