Codex 的修复测试全绿,运行时一上线就崩

Codex 的 PR 在 CI 里全绿,但部署后应用就崩。为啥 agent 修复打中的是测试面而不是 runtime,以及那道能补上 gap 的 smoke 关卡。

Codex 关掉了 issue、开了 PR,全绿。你 merge。部署完五分钟,错误率飙起来。所谓 “修复” 在测试里通过,是因为测试 mock 掉了 agent 顺手改坏的那个网络调用、stub 掉了 agent 重命名的那个 env var、或者跑在 JSDOM 里根本走不到那段坏掉的代码。测试通过本来意味着 “没回归”,但 Codex 被训练的目标就是让测试通过——而一个全绿的 suite 是 “真实用户没事” 的一个很差的代理指标。

最快修复: 别再单凭绿 CI 信任 agent PR。加一道便宜的 runtime 关卡——把 PR 部署到一个 ephemeral preview,跑一次 5 秒的 smoke 检查(首页返回 200、一个关键 API 调用能成),并把它设成 merge 前的 required 状态检查。光这一步就能挡掉绝大多数 “CI 绿、上线崩” 的 bug,而且完全不用动你的测试 suite。下面的内容都是把这道关卡磨得更锋利,并教会 Codex 去打 runtime 而不是测试面。

这事为啥对 Codex 尤其要紧:每个任务都跑在一个 ephemeral container 里,env var 是合成的、网络访问按 workspace 受限、没有生产数据的形状、RLS 或 auth 策略也不生效。“测试在 sandbox 里过了” 是一句关于无菌室的话,不是关于生产环境的话。截至 2026 年 6 月(Codex 3.0,即 “build, test and debug on autopilot” 那一代),sandbox 更强了,但这道无菌室的 gap 没变。

这是最伤口碑的失败模式之一,因为 PR 看起来太值得信赖了。每个 box 都打勾了。你用来过滤 agent 工作的信号——绿 CI——给了你一个假阳性。

你属于哪一类

先把 agent 的 PR 跟 origin/main diff 一下,在动手 debug 之前看看有没有这些特征:

你在 diff 里看到的大概率原因跳到
函数签名变了,jest.mock/vi.mock 是旧的mock 匹配的是旧行为原因 1
新加的 process.env.NODE_ENV 或其他 env 门控分支prod-only 路径在 CI 里从不执行原因 2
测试 import 的是 helper,不是实际部署的入口handler/包装层没测原因 3
.snap__snapshots__/ 和生产代码一起改了agent 重写了自己的契约原因 4
stub 的异步调用附近加/删了 await同步 fake 掩盖了缺失的 await原因 5
任务日志里只跑了 npm testintegration/e2e 从未被调用原因 6
新加的 .skip.todoxit,或 expect 外面的 try/catchagent 把测试静音了原因 7

常见原因

1. Agent 改了生产代码,测试里的 mock 还匹配旧行为

函数签名从 fetch(url) 改成 fetch(url, opts)。真实调用方传了 opts。测试 mock 了 fetch 但忽略第二个参数,于是过了。生产环境 opts 是 undefined 直接炸。

如何判断:grep agent 改过的那个函数所在测试文件里的 jest.mockvi.mocksinon.stub。如果 mock 签名是旧的,这个测试已经不在测真东西了。

2. 测试跑 NODE_ENV=test,但 agent 加了 NODE_ENV 分支

Agent 加了 if (process.env.NODE_ENV === 'production') 守卫。新代码只在 prod 走。CI 跑 NODE_ENV=test,新代码从未执行。测试过了;NODE_ENV=production 一开就崩。同样的坑适用于任何只存在于 sandbox 的值:在 Codex container 里,只在生产环境存在的 secret 和 config 干脆是缺的,所以一个读 process.env.STRIPE_KEY 的分支在任务里可能走 “缺 key” 那条,在 prod 里走 “真 key” 那条。

如何判断:diff 里有 NODE_ENV 或任何 env 门控分支。看 agent diff 里有没有 process.env.X

3. Agent 改的是 serverless handler,测试只测了里面的纯函数

Lambda handler 引入一个 helper。测试直接 import helper 用手工拼的 event 调用。handler 包装层(request 解析、错误处理、response shape)根本没测。Agent 改坏的就是包装层。

如何判断:对比测试 import 的入口 vs. 平台实际调用的入口。如果是两套,测试根本看不到包装层的回归。

4. Snapshot 测试是过期的

Agent 的改动改变了输出。同一个 agent 顺手更新了 snapshot 来匹配。测试 “通过” 是因为 snapshot 被改坏输出的同一个 agent 重写了。

如何判断:PR diff 里同时改了 .snap__snapshots__/ 和生产代码。每个 snapshot 更新都得人工核——这是 agent 在替你立的新契约。

5. 测试用同步 fake 假装异步 API

真代码 await 一次数据库调用。测试返回一个已 resolve 的 promise stub。Agent 删掉了 await,stub 仍同步返回,所以缺 await 看着没事。生产环境函数返回 pending promise 而不是值,下游对 undefined.then,崩。

如何判断:agent diff 里加/删了 await。核对一下测试 fake 是真实异步行为还是直接短路了。

6. Agent 只跑了 unit suite,没跑 integration / e2e

Harness 配的是 npm test,映射到 unit only。Integration 测试在 npm run test:integration 后面,没被调用。Agent diff 破坏的恰是 unit suite 看不到的 integration 边界。

如何判断package.json 里有多个测试脚本,但 agent 只跑了一个。检查 harness 日志里实际执行了哪条命令。

7. Agent 把一个 flaky 测试关掉了,而不是修 bug

Agent 看到某个测试间歇性 fail。它加了 .skipit.todo,或用 try/catch 吞掉断言失败。测试通过是因为坏测试不再跑。底层的 bug 被掩盖了。

如何判断:diff 里出现 .skip.todoxitxdescribe,或断言外面新加的 try/catch。Agent PR 里删测试的动作都要额外审。

最短修复路径

Step 1:把 “deploy 后 smoke” 设成 required 状态检查

.github/workflows/agent-pr.yml 里,构建 PR、部署到 ephemeral preview,再 smoke 那个 live URL:

- name: Deploy to ephemeral env
  run: ./scripts/deploy-preview.sh
  env:
    NODE_ENV: production

- name: Smoke check
  run: ./scripts/smoke.sh "https://pr-${{ github.event.number }}.preview.example.com"

smoke.sh 打的是真正部署后的 URL:首页 200、登录流程能渲染、一个关键 API 调用能成。五秒钟,能挡掉绝大多数 “CI 绿、上线崩” 的 bug,因为它跑的是用 NODE_ENV=production 构建出来的真实产物,不是一个 mock 出来的无菌室。

两个让它真正生效的细节:

  • 设成 required 检查。 在 branch protection 里(Settings -> Branches -> branch protection rule -> “Require status checks to pass”)把 smoke job 标成 required。一道跑了但不挡 merge 的 smoke 步骤就是摆设——agent 自动 merge 会直接从红色 smoke 上面开过去。
  • 触发要绑在部署后的代码上,别靠人工反应。 如果你用平台 preview(Vercel、Cloudflare Pages、Netlify),把 smoke job 绑到 deployment_status 事件上,让它跑在该 commit 对应的那个确切 preview URL 上,而不是一个过期环境。

Step 2:禁止 agent 在同一个 PR 里改 snapshot

在 AGENTS.md 里(Codex 读取项目约定的文件——先读 root,再读嵌套目录,嵌套覆盖 root):

## Tests

- Never run `--updateSnapshot`, `-u`, or `npx vitest --update`.
- If a snapshot is stale, stop and ask the human reviewer.
- Snapshot diffs in your PR will be treated as the contract you are claiming. They will be reviewed by a human.

再加一道保险:在 agent 的 CI 环境里设 CI=true(或 CI=1)。Jest 和 Vitest 在 CI 为真时都拒绝写或更新 snapshot——Vitest 在 snapshot 缺失/不匹配时直接 fail,Jest 则报错说必须显式传 --updateSnapshot。光这一条就能挡住 agent 在任务里偷偷重新生成 snapshot。然后再加一条 CI 检查:agent commit 里 .snap 文件如果和生产代码一起改动就 fail,这样即便 snapshot 是更早提交的,漂移也能被确定性地逮到。

Step 3:禁止把测试静音

## Tests (continued)

- Never add `.skip`, `.todo`, `xit`, `xdescribe`.
- Never wrap an `expect(...)` in try/catch.
- A failing test means there is a bug. Find and fix the bug, do not hide the test.

再加一条针对 agent 新增内容的 grep CI 检查:

if git diff origin/main...HEAD -- 'src/**/*.test.*' | grep -E '^\+.*\.(skip|todo)\b|^\+.*xit\(|^\+.*xdescribe\('; then
  echo "Agent disabled tests. Reject."
  exit 1
fi

同一招还有个更隐蔽的变体是 .only——agent 把跑测试的范围缩到自己那一个通过的测试上,于是其他每个测试都悄悄不执行了。给 Vitest 传 --allowOnly=false(或用 jest --ci,它在残留 test.only 时会 fail),这样一个漏下的 .only 会让 CI 失败,而不是悄悄把 suite 缩小。

Step 4:agent CI 不能只跑 unit

- run: npm test            # unit
- run: npm run test:integration   # real DB, real HTTP
- run: npm run test:e2e -- --headed=false   # at least one critical path

e2e suite 慢的话挑一两个旗舰路径,每个 agent PR 跑这点就够。全量 e2e 留到 nightly。

Step 5:测试脚本本身就跑一次 production 模式 smoke

加一个用 NODE_ENV=production 启动应用、断言关键端点的测试:

// test/smoke.prod.test.js
import { spawn } from 'node:child_process';
import { test, expect } from 'vitest';

test('app starts in production mode and responds 200', async () => {
  const proc = spawn('node', ['dist/server.js'], { env: { ...process.env, NODE_ENV: 'production', PORT: '4040' } });
  await waitForPort(4040, 5000);
  const res = await fetch('http://localhost:4040/healthz');
  expect(res.status).toBe(200);
  proc.kill();
});

能逮到 unit suite 走不到的 env 门控分支。

Step 6:核对 mock 和真实签名是否一致

写一个静态检查,验证 mock 的形状和真实 export 一致:

// scripts/check-mocks.mjs
// For each jest.mock('../foo'), import ../foo and verify the mock matches its exports' shape

不用全实现——30 行的检查,mock 的 key 和真实模块对不上就 warn,能逮到最常见的漂移。

Step 7:PR 描述里强制写 runtime 行为

## PR description template (mandatory)

- What changed at runtime: ...
- What env vars or feature flags this depends on: ...
- What I manually verified (commands, URLs): ...
- What I did NOT verify and why: ...

Agent 被迫描述 runtime 行为,gap 自然浮出来。“我没验证 lambda handler 包装层” 是有用的坦白。

怎么确认修好了

下一个 agent PR 满足下面所有条件,才算把 gap 补上了:

  1. PR 的状态列表里出现一个 smoke / preview 检查,并且它在 branch protection 里被标成 Required(一个绿但非 required 的检查不算数)。
  2. smoke job 的日志显示它在 NODE_ENV=production 下打了一个真实 URL,至少一条关键路径返回了 200
  3. smoke 检查 pending 或红的时候自动 merge 完不成——开一个一次性 PR 故意把首页路由弄坏,看着 merge 一直被挡住来验证。
  4. 你的 grep 检查能让一个加了 .skip 或在 src/ 旁边动了 .snap 的测试 PR fail,证明它们真的在跑。

如果一个已知有问题的 PR 还是 merge 进去了,那说明检查不是阻塞性的、或者作用域配错了文件——在再次信任任何 agent PR 之前先把 branch protection 修好。

这事不一定怪你

如果你的测试框架的 mocking 原语本身就鼓励过期 mock(比如 type 信息全擦的 jest.mock('module'),没签名检查),任何 agent 规则都救不全你。逐步往 typed test double(vi.fn<typeof real>()sinon.stub<Real>()ts-mockito)迁移,让签名漂移变成编译错误。

容易误诊成什么

“Agent 在 hallucinate”。它没有——每一处改动都真实且本地一致。问题在于测试 suite 对真实表面覆盖不到位,不在 model 推理上。继续加 agent 规则、不加 runtime 检查不会有用。

Prevention

  • 每个 agent PR 跑 ephemeral preview deploy + smoke
  • AGENTS.md 禁掉 snapshot 更新、test skip、断言吞掉
  • CI grep 检查 agent commit 里的 .skip.todoxit、snapshot 改动
  • 每个 agent PR 必须跑 integration + 至少一条关键 e2e,不只 unit
  • 有一个跑在 NODE_ENV=production 的 smoke 测试覆盖关键端点
  • 定期 audit mock 签名是否还和真实 export 对得上
  • PR 模板强制 agent 声明做了什么、没做什么

FAQ

  • 要不要直接关掉 agent PR 的 green-CI 自动 merge? 如果你今天加不了 runtime smoke 步骤,那就关——这是安全的默认值。但配上 required 的 preview-smoke 检查后,自动 merge 是没问题的;目标是让关卡里有一个真实的 runtime 信号,而不是禁掉自动化。
  • e2e 太慢没法每个 PR 跑。 挑一个关键路径,每个 agent PR 跑这条。全量留 nightly。跑一条真路径胜过跑零条。在 preview 上对 /healthz 做一次 5 秒的 curl,在抓部署时崩这件事上已经胜过一整套全绿的 unit suite。
  • 为啥测试在 Codex 那过了,在我本地 / prod 却挂? Codex 跑在一个 ephemeral sandbox 里,env var 是合成的、网络受限、没有生产数据形状也没有 auth 策略。一段在缺 secret、真实网络延迟、或 prod-only config 上分叉的代码,在那里会走另一条路径。复现方法:把生产环境那样的 env var 设上、用 NODE_ENV=production 跑同一套 suite。
  • 能在 AGENTS.md 里让 Codex 跑 integration suite 吗? 能。AGENTS.md 从 repo root 和嵌套目录读取(嵌套覆盖 root)。把确切命令写上,比如 “Run npm run test:integration before opening a PR; do not consider the task done until it passes.”。Codex 会照你点名的命令跑,而不是去猜 npm test
  • Agent 更新了一个 snapshot 但 PR 还是绿的,这种情况有合理的时候吗? 只有在人工把 .snap diff 一行行读完之后才算——这是 agent 正在主张的新契约。把 snapshot 改动当成 API 改动看,别当格式改动。先设 CI=true,让 agent 一开始就没法偷偷重新生成 snapshot。
  • 既然不是 model 在 hallucinate,那我到底该修什么? 修的是测试 suite 对真实表面的覆盖,不是 prompt。加上 runtime smoke 关卡、跑 integration 加一条 e2e 路径、禁掉 snapshot/skip 改动。光加 agent 规则、不加 runtime 检查不会有任何改善。

标签: #Codex #AI 编程 #排查 #测试