Skip to content

Latest commit

 

History

History
251 lines (175 loc) · 8.23 KB

File metadata and controls

251 lines (175 loc) · 8.23 KB

变异测试指南

验证 AI 生成测试的可信度:让测试证明自己有效。 最后更新:2026-04-24

返回文档索引


背景:代码覆盖率是谎言

TokenRouter 的大量测试由 AI Agent 生成。AI 生成的测试有一个根本性缺陷:它和它所测试的代码犯的是同一种错误

举一个真实案例(来自 Atlassian 工程团队):

// AI 生成的实现
func dedup(items []Item) []Item {
    seen := map[*Item]bool{}  // 引用相等,不是业务 key 相等
    // ...
}

// AI 生成的测试
assert.Equal(t, 3, len(result))           // ✅ 通过
assert.True(t, containsAll(result, expected))  // ✅ 通过(测试用了同一批指针)

两者同时错,测试通过,生产环境悄无声息地双重计账。

代码覆盖率衡量代码是否被执行。变异测试衡量如果代码错了,测试是否会失败。


核心原理

变异测试通过向被测代码引入小的故障(变异体),然后运行测试套件,验证测试是否检测到了变化。

原始代码:   if err != nil { return err }
变异体 1:   if err == nil { return err }   ← 条件取反
变异体 2:   删除整个 if 块                  ← 删除语句
变异体 3:   return nil                      ← 替换返回值

每个变异体的结果:

状态 含义
KILLED 测试检测到变异 → 这是有效断言
LIVED 测试没发现变异 → 此处断言是空壳
NOT COVERED 没有测试覆盖到这行代码
NOT VIABLE 变异导致编译失败,不计入统计
TIMED OUT 变异引入了无限循环,视为 KILLED

变异分数(Mutation Score)= KILLED 数 / (KILLED + LIVED) 数


为什么 AI 生成的测试变异分数更低

同等行覆盖率下,AI 生成的测试存活的变异体比人工测试高 15-25%。原因:

  1. 对称错误:AI 在测试中复制了实现的逻辑假设,双方都错
  2. 语义盲点:AI 不理解业务语义("相等" 应该是 key 相等还是引用相等?)
  3. 弱断言倾向:AI 偏好断言"不报错"而非断言"结果正确"
  4. 边界不覆盖:AI 不知道哪些边界在业务上是危险的

Go 工具:Gremlins

TokenRouter 使用 Gremlins,专为微服务设计,适合此类规模的 Go 项目。

安装

# macOS
brew install go-gremlins/tap/gremlins

# 或者直接下载二进制
go install github.com/go-gremlins/gremlins/cmd/gremlins@latest

基本用法

# 对整个模块运行(dry-run,仅列出可变异的位置)
gremlins unleash --dry-run

# 对整个模块运行变异测试
gremlins unleash

# 只测指定包(推荐:先从核心模块开始)
gremlins unleash --coverpkg ./internal/canonicalizer/...
gremlins unleash --coverpkg ./internal/hasher/...

# 只测 PR 变更的代码(速度快,适合 CI)
gremlins unleash --diff-origin main

读懂报告

Mutation testing completed. Elapsed 42s.
Files mutated: 8  Mutants tested: 104
Killed: 79  Survived: 18  Not covered: 7

Mutation score: 81.44%

internal/hasher/hasher.go:45:12   LIVED  [CONDITIONALS_BOUNDARY]
internal/hasher/hasher.go:67:8    LIVED  [REMOVE_VOID_CALL]
internal/canonicalizer/json.go:23 NOT COVERED

LIVED 的行就是需要补测试的地方。 每个 LIVED 变异体对应一种"你的测试无法检测到的代码错误"。


各模块变异分数目标

与覆盖率要求对应,变异分数有更严格的语义。

模块 最低变异分数 原因
Canonicalizer 95% 字节级正确性,任何偏差都会破坏哈希一致性
Hasher 90% 相同输入必须产生相同哈希,错误静默且难以排查
Chunker 80% 切分逻辑错误会静默传播到下游
Arranger 80% 排序错误直接影响缓存命中率
Dedup 80% 并发逻辑错误,race condition 危险
Auth Middleware 85% 安全相关,认证绕过是高危场景
Outbound Adapters 75% 格式转换错误会被上游 API 400 拒绝

杀死存活的变异体

发现 LIVED 变异体后的处理步骤:

1. 理解变异类型

Gremlins 支持五种变异类型:

变异类型 示例 需要的测试
CONDITIONALS_BOUNDARY >>= 边界值测试(恰好等于边界的输入)
NEGATE_CONDITIONALS != nil== nil 正向和反向的错误路径都要测
REMOVE_VOID_CALL 删除函数调用 验证该调用的副作用确实发生
ARITHMETIC_BASE +- 验证计算结果的具体数值,而非"不为零"
INCREMENT_DECREMENT i++i-- 验证循环次数、计数器最终值

2. 针对性补测试

以 Hasher 为例,假设出现 LIVED:

// hasher.go:45 — 存活的变异体:if len(blocks) > 0 → if len(blocks) >= 0

这意味着你的测试从没有用空 blocks 调用过 hasher,或者调用了但没断言结果。修复:

func TestHasher_EmptyBlocks(t *testing.T) {
    h := New()
    hash, err := h.PrefixHash([]block.Block{})
    require.NoError(t, err)
    // 空 blocks 应该返回确定性的哈希值,而不是空字符串
    assert.Len(t, hash, 64, "empty blocks should still produce a valid SHA256 hash")
}

3. 等价变异体(不需要处理)

部分变异体在语义上等价于原始代码,无论如何测试都会 LIVED。这是正常的,不需要强行 kill。

常见的等价变异体:

  • 日志语句的变异(REMOVE_VOID_CALL 作用在 log.Debug
  • 无副作用的优化路径(if len(s) == 0 { return "" } 的边界变异)
  • Prometheus 指标记录语句

对等价变异体,在代码中加注释或用 //gremlins:ignore 标记跳过。


CI 集成

推荐的 CI 工作流

AI 或开发者提交 PR
    │
    ├── 1. go test ./... -race                  → 必须全绿
    │
    ├── 2. go vet ./...                         → 必须无警告
    │
    ├── 3. gremlins unleash --diff-origin main  → 变异分数不低于基线
    │        ↓
    │     核心模块(Canonicalizer/Hasher/Auth): ≥ 85%
    │     其他模块:                            ≥ 75%
    │
    └── 4. 人工 review LIVED 列表               → 手动判断是否需要补测试

为什么只测 diff

--diff-origin main 只对 PR 变更的行引入变异体,把运行时间从几十分钟压缩到 2-5 分钟,同时聚焦在最需要验证的新代码上。

Makefile 目标(可选添加)

# 变异测试 — 只测 diff(CI 用)
test-mutation-diff:
	gremlins unleash --diff-origin main

# 变异测试 — 指定包(本地调试用)
test-mutation-pkg:
	gremlins unleash --coverpkg $(PKG)

# 变异测试 — 全量(发布前)
test-mutation-full:
	gremlins unleash

不适合运行变异测试的场景

变异测试有代价,以下情况不需要强制运行:

场景 原因
快速实验/原型代码 接口未稳定,测试即将被丢弃
纯粹的数据模型定义 没有逻辑可以变异
日志/指标收集代码 等价变异体比例高,信噪比低
大型 Go 模块 Gremlins 不适合 monorepo 级别,单次运行可能耗时数小时

参考资料


相关文档