[控场AI]
· 13 分钟阅读· 6,579 字

让AI编程代理修复Flaky测试前,先让它稳定失败

让AI编程代理修复Flaky测试前,先让它稳定失败

把flaky测试固化为可复现的命令加种子,让代理用脚本而非“看起来完成”判定修复。

文章指出,AI 编程代理以“看起来完成”为唯一停止信号,而 flaky 测试恰好最擅长制造这种假象:跑两遍都绿,代理便宣布修复完成,却什么也没证明。解决办法是先把 flaky 测试变成可复现的失败——一条命令加一个种子,能在几分钟内稳定失败——再交给代理一个必须连续通过数百次的检查脚本。文章区分了两类 flake:任意顺序下失败的(数据竞争、超时、时钟)靠重复量暴露,顺序依赖的(全局状态污染、残留数据)靠多种子多顺序暴露。随后给出 Go、pytest、Vitest 与 Jest 的具体命令与陷阱,例如 Go 的 `-count` 只重复同一洗牌顺序、需要外层循环换种子,以及测试缓存会让重复运行假通过。最后给出检查脚本、任务简报模板和 Hermes 中的实操流程,并提醒运行次数应由失败率决定。

你告诉AI编程代理“修复那个偶尔失败的结账测试”,它跑两遍测试,都绿了,于是报告任务完成。但两次通过恰恰是坏版本最常见的表现——代理什么都没证明。这篇文章讲的是:在把任务交给代理之前,如何让flaky测试按需稳定失败,并写下一个可复现的命令和种子;然后交给代理一个必须连续通过数百次的检查。

截图

问题:"看起来完成了"是代理唯一的信号

Claude Code的最佳实践页面明确指出:"Claude在工作看起来完成时就会停止。没有一个它可以运行的检查,'看起来完成'是唯一可用的信号。" Flaky测试是"看起来完成"的最坏情况——它大多数时候都能通过,代理很容易被误导。

一个可复现的失败不是一条偶尔变红的CI链接,而是一条命令加一个种子,能在几分钟内稳定失败。要得到它,你需要先弄清楚自己面对的是哪种flake。

两类Flaky测试,两种找法

第一类在任何顺序下都会失败:数据竞争、过紧的超时、时钟问题、单个测试内部的共享状态。你需要靠"量"来暴露它——把同一个测试跑上几百次。

第二类取决于之前跑了什么:被污染的全局变量、其他测试留下的数据库行、缓存。你需要靠"顺序"来暴露它——用多个不同顺序运行,每个顺序都对应一个可重放的种子。原文的配图很好地概括了这种处境:在一长排黑色勾选块中,只有一块裂开的橙色块藏在放大镜下。

Go:-count 重复顺序,-shuffle 决定顺序

go test 同时支持两者。-count n 会把每个测试运行n次,-shuffle on 随机化测试顺序并报告种子以便复现,-race 开启竞争检测。先对单个测试做量级测试:

go test ./internal/cart -run '^TestCheckoutTotals$' \
  -count=500 -race -failfast -timeout=30m

-failfast 在首次失败时停止,保持输出简短。-timeout 同样重要:默认10分钟,而竞争检测器文档说典型程序"内存使用可能增加5-10倍,执行时间增加2-20倍"。500次带竞争检测的运行如果超过10分钟,测试二进制会panic,看起来就像另一个与bug无关的失败。

如果单独跑这个测试从不失败,它可能依赖其他测试,这时你需要顺序。Go源码里有一个会改变命令写法的细节:在 testing.go 中,M.Run 只对顶层测试洗牌一次,打印种子,然后才进入 -count 循环。所以 -count=500 -shuffle=on 是一个随机顺序跑500次,不是500个顺序。

要得到多个顺序,需要运行多个测试二进制,每个用你自己选的种子:

log=$(mktemp)
for i in $(seq 1 50); do
  seed=$RANDOM
  if ! go test ./internal/cart -count=10 \
      -shuffle="$seed" -race >"$log" 2>&1; then
    echo "FAILED: -shuffle=$seed"
    tail -n 40 "$log"
    break
  fi
done

失败时,go test ./internal/cart -count=10 -shuffle=<seed> -race 可以重放同一顺序。还有一个容易踩的坑:Go在你指定包时会缓存通过的结果,代理如果连续两次运行 go test ./internal/cart 且中间没有改动,第二次可能拿到缓存通过而非真实运行。文档称 -count=1 是"显式禁用测试缓存的惯用方式"。交给你代理的每条命令都要带上 -count。

竞态检测器(-race):Go 自带的动态分析工具,在编译时插入同步事件检测代码,运行时记录所有内存访问与 goroutine 同步操作,发现未同步的并发读写时报告数据竞争并以非零状态退出。官方文档指出,开启后内存占用通常增加 5–10 倍,执行时间增加 2–20 倍,因此 -timeout 必须相应调大,否则测试二进制会因超时 panic,产生与目标 bug 无关的失败。竞争检测只能发现实际执行到的竞争路径,重复次数越多,命中窗口的概率越高,这正是要靠 -count 放大执行量的原因。

pytest:pytest-repeat 管量,pytest-randomly 管顺序

Python需要两个插件。pytest-repeat 提供 --count,其README指出可以配合 -x "让测试运行器在首次失败时停止"。pytest-randomly 打乱测试顺序,在开始时打印种子(其示例为 Using --randomly-seed=1553614239),并在"每个测试的setup、run和teardown之前"重置Python全局 random 种子。所以一个种子同时重放顺序和任何从 random 抽取的数据。

python -m pip install pytest-repeat pytest-randomly
pytest "tests/test_cart.py::test_checkout_totals" \
  --count=500 -x -q

和Go一样有个陷阱:一次pytest运行只有一个种子,500个副本都在同一个洗牌顺序下运行。要多个顺序,把下面这条放进Go那节的循环里,自己传种子:

pytest tests/ -x -q --randomly-seed="$seed"

--randomly-seed=last 可以重跑上一个种子,前提是pytest的缓存提供者插件处于启用状态。

然后关掉插件,让每个测试按文件顺序跑50次:pytest tests/ -x -q -p no:randomly --count=50。不要把 -p no:randomly 加到循环里,因为插件关闭后pytest不再认识 --randomly-seed,每次运行都会以用法错误退出。如果洗牌时失败但文件顺序从不失败,说明测试依赖其他测试的残留;如果两种方式都失败,查时间、线程、网络或数据库。

pytest 插件机制与缓存提供者:pytest 通过 setuptools 入口点发现第三方插件,无需修改代码即可扩展命令行选项与钩子。pytest-randomly 利用这一机制注册 --randomly-seed 选项,并在每个测试的 setup/run/teardown 前后重置全局 random 种子,保证同一 --randomly-seed 下从 random 抽取的数据序列可复现。--randomly-seed=last 依赖 pytest 内置的缓存提供者插件(cacheprovider)保存上次种子;若用 -p no:cacheprovider 禁用缓存,该选项即失效。同理,-p no:randomly 会完全卸载插件,pytest 不再识别其命令行选项,这正是循环中不能加该参数的原因。

Vitest 和 Jest:自己选种子

Vitest 5 可以从命令行同时做两件事。--repeats <number> 会"无论结果如何,把每个测试重复指定次数",--sequence.shuffle 配合 --sequence.seed 给出带种子的顺序。序列配置文档说种子默认是 Date.now(),但没有说Vitest会打印它。所以自己传,你总能知道哪个失败了:

npx vitest run src/cart --repeats=50 --bail=1
npx vitest run --sequence.shuffle \
  --sequence.seed="$seed" --bail=1

Jest 30 有 --randomize,打乱文件内测试并显示种子,仅适用于默认的 jest-circus 运行器。--seed=<num> 设置种子,Jest也用它打乱测试套件的运行顺序,取值必须在 -2147483648 到 2147483647 之间。bash的 $RANDOM 在0到32767之间,所以放得下。对Jest来说,循环负责重复:

npx jest src/cart --randomize --seed="$seed" --bail

种子(seed)与伪随机数生成器:洗牌顺序由伪随机数生成器按种子产生,同一种子必然得到同一排列,这是“可重放”的基础。Vitest 的 --sequence.seed 默认取 Date.now() 但不会回显,Jest 的 --seed 有 32 位有符号整数范围限制,超出即报错。bash 的 $RANDOM 返回 0–32767,安全落在 Jest 范围内,但熵很低,大规模循环中可能出现重复种子,重复种子意味着重复顺序,会稀释多顺序覆盖的效果;对顺序依赖型 flake,可改用 $RANDOM$RANDOM 或从 /dev/urandom 取值来扩大种子空间。

检查脚本和任务简报

把循环放进一个只在所有运行通过时才退出0的脚本。你运行它,代理也运行它,没人需要判断"看起来没问题"。

#!/usr/bin/env bash

**Stop hook 与代理的完成判定**:Claude Code 的 Stop hook 是在代理准备结束一轮对话时触发的用户自定义脚本,钩子以非零状态退出即可阻止本轮结束并把输出反馈给代理,迫使其继续工作。这为“完成”提供了外部强制点:把 `scripts/flaky-check.sh` 挂到 Stop hook 上,代理就不能凭“测试看起来绿了”收工,而必须让脚本退出 0。注意钩子脚本本身应保持快速失败并限制输出长度,否则代理可能在反复重试中消耗大量上下文;同时钩子不替代人工复核,仍需用 `git diff` 确认代理没有改动断言或检查脚本本身。
# scripts/flaky-check.sh
# Exit 0 only if RUNS seeded runs all pass.
set -u
RUNS=${RUNS:-50}

run_once() {
  go test ./internal/cart -count=10 -shuffle="$1" -race
}

log=$(mktemp)
for i in $(seq 1 "$RUNS"); do
  seed=$RANDOM
  if ! run_once "$seed" >"$log" 2>&1; then
    echo "run $i/$RUNS FAILED, seed $seed"
    tail -n 40 "$log"
    exit 1
  fi
done
echo "$RUNS/$RUNS runs passed"

50次 -count=10 就是包内每个测试的500次执行,跨50个顺序。对pytest,run_once 的函数体换成 pytest tests/ -x -q --count=10 --randomly-seed="$1";对Vitest或Jest,用上面命令并把 "$1" 作为种子。

需要多少次运行取决于失败率,所以先用循环测量。如果50次执行中失败1次,而"修复"没有改变任何东西,那么脚本里500次执行(50次运行×-count=10)全部通过的概率是 0.98^500,约两万四千分之一。如果失败率是千分之一,仍然坏着的测试通过这500次执行的概率是61%(0.999^500)。稀有flake需要更大的数字。依赖顺序的flake没有稳定的每次执行失败率,因为它只在某些顺序下失败,所以对这类要把 RUNS 调大以尝试更多顺序。

然后是任务简报。下面的 8812 代表你的循环打印出来的种子:

Fix the flaky test TestCheckoutTotals in ./internal/cart.

Reproduce first. This fails within a few minutes:
  go test ./internal/cart -count=10 -shuffle=8812 -race
Run it before you change anything and confirm it fails.

Failure:
  <paste the failing assertion and any race report>

Rules:
- Find the cause. No retries, sleeps, t.Skip or
  longer timeouts.
- Don't change the test's assertions or
  scripts/flaky-check.sh.

Done when both pass:
  go test ./internal/cart -count=10 -shuffle=8812 -race
  scripts/flaky-check.sh

先复现是Claude Code文档自己针对bug的建议:"写一个能复现问题的失败测试,然后修复它。"如果你想在Claude Code内部强制执行这道门,同一份文档描述了一个Stop hook,它"把你的检查作为脚本运行,并阻止对话轮次结束直到它通过"。

在 Hermes 中把循环运行在代理旁边

(我开发Hermes IDE,所以这段请自行权衡。)上面所有内容在任何终端里都能用。Hermes把代理、你的复现循环和裁决放在同一个窗口里。

按 ⌘N(Windows和Linux是Ctrl+Shift+N),把任务简报粘贴进去。代理——比如Claude Code或Codex——在真实终端里以你自己的登录状态启动,在自己的 hermes/<name> 分支和worktree上,简报作为它的第一条提示。

然后向右或向下分割窗格,在代理旁边打开一个普通shell。git worktree list 显示任务文件夹,cd 进去,在代理第一次编辑前和最后一次编辑后各手动运行一次带种子的复现。有一点需要注意:你的循环和代理自己的测试运行会争抢CPU,这会改变时序。竞争可能在两者都繁忙时出现得更频繁或更少,所以最终检查要在代理空闲时运行。

脚本始终是"完成"的定义,所以落地前在那个窗格里运行它。然后打开Review Desk(⌘G,或Ctrl+Shift+G)逐轮阅读改动,检查规则是否被遵守:没有新增sleep,没有retry,没有放宽断言。对于检查脚本,普通git一行就能回答:

git diff main...hermes/<name> -- scripts/

输出为空意味着代理没有动你的检查。

为什么这值得做

Flaky测试先是CI问题,然后才是代理问题。一旦流水线随机变红,人们就学会点重跑,真实的失败就藏在噪音里。把复现固化成命令、种子和脚本,不仅让代理有了可验证的"完成"定义,也让整个团队从"看起来没问题"的猜测中解放出来。

#flaky test#AI编程代理#Claude Code#测试复现#go test shuffle#pytest-randomly#Vitest repeats#CI测试
分享:

相关推荐