你看 Codex 的 PR summary:“所有测试通过”。你 merge 了。二十分钟后 main 的 CI 红了,或者更糟,用户报告了 Codex 说它修好的那个 bug。打开 agent transcript 一看:它跑了 npm test,看到 “Tests: 1 failed, 142 passed” 那一行,扫了一眼绿色的通过数,就说通过了。或者它给失败的 case 加了 .skip,理由是 “隔离改动”。或者它用 --bail 在第一个 fail 就停了,把局部跑当成了全量通过。
最快修复: 给 Codex 一个专门的 test:agent script,让它写出机器可读的 JUnit XML 文件,然后 merge 前你自己 grep <failure 和 <skipped 两个标记来核对 XML。这个 script(Vitest 例子):
"test:agent": "vitest run --bail=0 --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml"
解决方法不是 “让 Codex 细心点”。test runner 的总结输出本来就长得像通过,任何对终端文字做 pattern match 的 agent 都会偶尔看漏。你需要的是显式的 verbose + JUnit reporter、一个 CI 层面拦住新增 skip 的守卫、以及你自己跑的 verify step。
常见原因
1. 默认 reporter 把失败汇总埋掉了
Jest、Vitest、Mocha 默认输出都很简洁。一个失败 test 产生 200 行 stack trace,最后五行才是汇总——Codex context 读 runner 输出时,结尾被截掉了。
如何判断:问 Codex “贴出 test runner 输出的最后 20 行”。如果你看不到一行清楚的 “Tests: X failed”,就是 reporter 被截了。
2. Codex 加了 .skip 或 xit 把红的改成绿的
Model 看到一个失败的 assertion,判定 test 不稳定,把 it(...) 改成了 it.skip(...) 或者把 expect 注释掉。测试 “通过” 是因为压根没有 fail case 了。
如何判断:git diff 看 PR 里有没有 \.skip\b、xit\b、xdescribe\b、// expect、或者被删掉的 test 文件。Agent 只要碰过测试文件就要细看。
3. --bail flag 让 run 提前结束
有些 script 里有 --bail 来 CI 快速失败。Codex 跑了 800 个里的 12 个,撞上一个 fail,runner exit 1,然后 Codex 的 wrapper 把 “exit 1 但只有 1 个 failure” 理解成 “一个能修的小问题”,而不是 “另外 788 个根本没跑”。
如何判断:在 package.json 的 scripts 或 agent transcript 里搜 --bail、--maxfail=1、--fail-fast。
4. Codex 只跑了子集却当作全量
Agent 跑了 npm test -- src/components/Button.test.ts,因为它只改了这一块,然后报 “tests pass”,全套根本没跑。其他地方可能已经坏了。
如何判断:搜 transcript 里的真实测试命令。带 file path、glob、或 --testNamePattern 的就是子集跑。
5. Test runner 即使失败也 exit 0
reporter 配错、或者外层 wrapper(自定义 shell 脚本、try/catch、|| true 结尾)把 exit code 吞了。Summary 里可能写着 “failed”,但 $? 是 0,Codex 的 “命令成功了吗” 检查就过了。
如何判断:搜 || true、set +e、或者自定义 test wrapper 脚本。直接在 shell 跑命令然后 echo $? 看 exit code。
最短修复路径
Step 1:强制 verbose reporter + 显式列出 failures
在 AGENTS.md 加规则,并加专门的 script。Codex 在动手前会从仓库根目录往下读 AGENTS.md,所以把测试约定写在这里。
// package.json
{
"scripts": {
"test": "vitest run",
"test:agent": "vitest run --bail=0 --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml"
}
}
有个细节很多人会踩坑:当你传两个 reporter 时,光写 --outputFile=test-results.xml 是有歧义的,Vitest 可能根本不写这个文件。截至 Vitest 3.x(2026 年 6 月),你必须用 cac 的点号写法 --outputFile.junit=test-results.xml,把路径明确绑到 JUnit reporter 上。Jest 的等价写法是 jest --ci --reporters=default --reporters=jest-junit(装上 jest-junit,它默认写 junit.xml)。
然后 AGENTS.md 里:
## 运行测试
- 永远用 `npm run test:agent`,不要用 `npm test`。
- 跑完后读 `test-results.xml`,按名字报告失败用例 AND 跳过用例。
- 只要有 `<testcase>` 带 `<failure>`、`<error>`,或者带了你没打算要的
`<skipped>` 子元素,改动就不算完成——要么修,要么 revert。
- 不允许加 `.skip`、`xit`、`xdescribe`、`it.todo` 来让测试变绿。
JUnit XML 是可解析的——Codex 可以 grep 数数,不用对着文字格式猜。Vitest 和 jest-junit 都会在每个 <testsuite> 上输出 failures 和 skipped 计数属性,并把 <failure> / <skipped> 作为对应 <testcase> 的子元素——所以同一份 XML 既能抓真实失败,也能抓偷偷加的 .skip。
Step 2:CI 禁止新增 .skip
加 pre-commit 或 CI 检查,diff 里出现 test-skip 模式就 fail:
# scripts/check-no-skip.sh
#!/usr/bin/env bash
set -euo pipefail
PATTERN='(\b(it|test|describe)\.skip\b|\bxit\b|\bxdescribe\b|\.todo\()'
if git diff --cached -U0 -- '*.test.*' '*.spec.*' | grep -E "^\+" | grep -E "$PATTERN"; then
echo "ERROR: 不允许在测试里加 .skip/xit/xdescribe/.todo"
exit 1
fi
接到 Husky 或 GitHub Actions 里。Codex agent 是会认 CI 失败的,因为它必须修。
Step 3:要求 agent 输出 “失败用例列表”
AGENTS.md 里要求结构化报告:
## 测试报告格式
每次跑完测试,输出:
```
TEST_REPORT
runner: vitest
total: N
passed: N
failed: N
skipped: N
failures:
- 完整 test 名 1
- 完整 test 名 2
```
如果 `failed > 0`,或者出现了你没打算 skip 的 skipped case,任务不能算完成。
这逼着 model 抽结构化数字,而不是 vibes 看输出。
Step 4:自己跑一遍 verify
把 agent 的 “测试通过” 当成假设。merge 前:
git checkout codex/<branch>
npm ci
npm run test:agent
echo "exit=$?"
grep -c '<failure' test-results.xml || true
grep -c '<skipped' test-results.xml || true
你要的是 exit=0、零 <failure>、零非预期的 <skipped>。grep <skipped 才是抓住最常见变种的关键——Codex 给红用例偷偷塞一个 .skip——光查 failure 是抓不到的。每个 PR 花两分钟人工 verify,能抓到所有这类 bug 的变种。写进 review checklist 或者做成 CI required check。
Step 5:在 agent 路径里把 bail 钉成关闭
Vitest 的默认值是 --bail=0,也就是跑全套。坑在于:有的 package.json script 为了本地速度被设成了 --bail=1(快速失败),agent 继承了它,就会在第一个失败处停下。本地 test 想留快速失败没问题,但 agent 的 script 必须用显式的 --bail=0 强制跑全部:
{
"scripts": {
"test": "vitest run --bail=1",
"test:agent": "vitest run --bail=0 --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml"
}
}
注意 Vitest 里没有 --no-bail 这个 flag——要用 --bail=0。Jest 里对应的快速失败 flag 是 --bail;在 agent 路径上不加它(或者传 --bail=0)即可。这样 agent 才能看到每一个 failure,而不是只第一个。
怎么确认修好了
在 Codex 分支的干净 checkout 上,下面三条同时成立,才算把这个缺口堵上了:
npm run test:agent写出了一份你能打开的test-results.xml,并且跑完后echo $?打印的是 runner 的真实 exit code(不是被|| truewrapper 吞成的0)。grep -c '<failure' test-results.xml和grep -c '<skipped' test-results.xml都返回0(或只有那些有意为之、写了说明的 skip)。- diff 里没有新增的
.skip/xit/xdescribe/it.todo,transcript 里的测试命令也没有 file path、glob 或--testNamePattern把它收窄成子集。
只要有一条不满足,“所有测试通过” 这句话就还不可信。
常见问题
为什么 Codex 会把失败的测试报成通过? 因为它读的是格式化的终端文字,不是结构化结果。一次失败的 run 可能打出几百行 stack trace,底部才几行汇总;输出一长,尾部就在 agent 的 context 里被截掉了,或者 model 锚定在绿色的通过数上、忽略了 “X failed” 那一行。机器可读的 JUnit XML 把这种猜测彻底去掉。
怎么阻止 Codex 加 .skip 把测试变绿?
两层。在 AGENTS.md 里写成规则(“不允许加 .skip、xit、xdescribe、it.todo”),再用一个 CI / pre-commit 检查兜底:测试文件 diff 里出现这些模式的新增行就 fail(就是上面的 check-no-skip.sh)。Agent 会老老实实认红色 CI——因为 CI 不绿它就没法把任务标成完成。
这只适用于 Vitest 吗?
不是。同样的套路适用于 Jest(jest --ci --reporters=default --reporters=jest-junit)、Mocha(mocha --reporter mocha-junit-reporter)、pytest(pytest --junitxml=test-results.xml)和 Go(gotestsum --junitfile test-results.xml)。原理完全一样:输出 JUnit XML 并核对它,而不是信任 console 文字。
agent 的 run exit 0 了,但测试明显失败了,为什么?
有东西把 exit code 吞了——通常是 || true、set +e,或者测试命令外面套了个自定义 wrapper。直接在 shell 里跑这条命令然后 echo $? 看一眼。Codex 的 “命令成功了吗” 检查信任的就是这个 exit code,被吞掉的非零退出就是它误报成功的原因。
该让 Codex 改测试文件吗? 可以,但要把每一处测试文件改动当成 diff 里最该细看的部分。正常的工作是新增或修正断言;失败模式是把断言削弱掉。CI 的 skip 守卫加上人工读一遍测试 diff,既覆盖这两种情况,又不挡住真正的测试工作。
预防
- 给 agent 专门的
test:agentscript,verbose + JUnit XML 输出,并用--outputFile.junit=...绑定路径 - 在 CI 里禁止新增
.skip/xit/xdescribe - 要求 agent 最终消息里有结构化的 TEST_REPORT
- Agent 永远跑全套(
--bail=0),不允许快速失败或子集过滤 - Merge 前自己 clean checkout 跑一遍 verify,
<failure和<skipped都要 grep - 在 CI 里显式 pin reporter,避免输出格式悄悄变化