AI 生成测试,重点不是数量而是命中风险

0阅读8分钟

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 应该为谁生成测试:

流程图画布 · 115%
Mermaid 流程图加载中...

四个完整案例:高价值 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(&#123; id &#125;) 测试就挂了,但行为没变。这种测试让维护者害怕改代码。

高价值测试:

// ✅ 覆盖错误映射契约和状态守卫
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

  1. Quality Assessment of Python Tests Generated by Large Language Models, ACM, 2025

  2. Evaluating LLM-Based Test Generation Under Software Evolution, arXiv, 2026

  3. AI Testing in 2026: Why Signal, Trust, and Intentional Choices Matter, Applitools, 2026 2

  4. Unmasking the Flaws: Why AI-Generated Unit Tests Fall Short in Real Codebases, Medium, 2025

  5. The Test Pyramid 2.0: AI-assisted testing across the pyramid, PMC, 2025

  6. A Practical Approach to Risk-Based Testing in 2026, testingmind, 2026

  7. The Practical Test Pyramid, Martin Fowler, 2018

评论 0

0 / 1000