Codex 测试建议太泛:6 个空话来源 + 锚到签名 + 类型 + bug 历史

「测一下 happy path 和 error path」是没用的填充——把测试绑到函数的真实签名、真实类型、edge case 和过去真出过的 bug 上。

你让 Codex 给 parseInvoice(input: InvoiceRaw): Invoice 出测试建议,它返回:「测 happy path、测 error path、测 edge case、测 boundary、测非法输入」。没一条引用真实签名,没一条提 InvoiceRaw 的真实形状、也没说你 domain 里”非法”到底指什么——这五条套到任何代码库里的任何函数上都成立。

最快修法: 泛测试建议来自泛 prompt。在同一个 prompt 里,用 name + 文件路径 指名函数,让 Codex 先读类型文件和现有测试文件,再要求它出绑到真实类型的测试。光这一处改动就能把模板变成可跑的测试。下面的步骤只是让这些测试更锋利——bug 历史回归测试、对抗式输入、以及一道”它到底编不编得过”的检查。

一条覆盖了你过去真出过的 bug 的测试,抵得上 10 条假想 edge case。接地比数量重要。

你属于哪一类?

把症状对到原因,然后跳到对应步骤。

建议长什么样最可能的原因修法
五条通用 bullet,没有字段名prompt 指了文件,没指函数Step 1
把输入当普通对象,没 enum / 字段细节Codex 没读类型定义Step 1(第 2 点)
重复你已有的测试;框架用错Codex 没读现有测试文件Step 1(第 3 点)
对你真出过的 bug 没有回归测试没喂 bug 历史Step 2
Mock 指错库(axios 而不是 kyCodex 猜了依赖Step 1 + 拒收循环
测试瞄准琐碎行(log、fallback)在冲覆盖率,不是降风险Step 3

常见原因

按命中率从高到低。

1. Prompt 指的是文件,不是被测函数

「给这个文件出测试建议」+ 一个 200 行的文件,会让 Codex 对冲:它一次性列出适用于文件里所有函数的测试。结果就是通用模板,不是函数特定。

如何判断:回看 prompt——如果被测函数没用「函数名 + 文件路径」同时指名,Codex 就是按整个文件工作的。

2. Codex 扫了函数,没读类型定义

函数收 InvoiceRaw。Codex 从没打开过这个类型的定义(在 src/types/invoice.ts)——所以它不知道哪些字段可选、有哪些 enum、合法性合同是什么。

如何判断:建议把每个输入都当成通用对象,没提具体字段、enum 值或来自类型的约束。

3. Codex 没检查现有测试

现有测试已经盖了 happy path,Codex 还建议「加一个 happy path 测试」——重复。或者它写 jest.mock(...),但你现有套件用的是 vi.mock(...)

如何判断:把建议和现有测试文件 diff——如果一半是重复或框架用错,说明 prompt 没说”先读现有测试”。

4. 没喂 bug 历史

Codex 不知道你这块出过 3 个 bug:一个时区 bug、一个闰日 bug、一个 Unicode normalization bug——建议里一条回归测试都没有。

如何判断:把建议对照你的已关闭 bug 列表。对这个文件跑 git log --grep="fix" -- src/parsers/invoice.ts。已知 bug 形状没盖到,就是你没喂进去。

5. Mock 配置错了,因为 Codex 猜了依赖

Codex 建议 mock axios,但你代码用 ky。或者建议 mock 数据库,但你的测试跑在真的测试 DB 上。Mock 形状对,目标错。

如何判断:建议测试里的 import 和同目录真测试里的 import 对不上。

6. 用 coverage 思维盖过实用思维

「为第 47 行加测试」,因为第 47 行没覆盖——但第 47 行是 if (debug) console.log(...)。这个测试零价值,只是把覆盖率往上抬。Codex 过度看重 coverage 指标,而不是抓 bug 的潜力。

如何判断:建议测试瞄准琐碎分支(log、fallback string)——这是在冲覆盖率,不是降真实风险。

最短修复路径

按收益从高到低。Step 1 和 Step 2 加起来,就能把泛建议变成可执行测试。

Step 1:锚到 函数 + 类型 + 现有测试

用这个模板:

为 `src/parsers/invoice.ts` 里的 `parseInvoice(input: InvoiceRaw): Invoice` 出测试建议。

出建议之前先:
1. 读 `src/parsers/invoice.ts`,把函数签名引述给我。
2. 读 `src/types/invoice.ts`,引述 `InvoiceRaw` 和 `Invoice` 类型。
3. 读 `src/parsers/invoice.test.ts`,总结已经覆盖了什么。

然后建议 5 个**新**测试(不重复现有覆盖):
- 每条指明具体输入形状和期望输出。
- 用真实类型:不要泛 `any`,不要编字段名。
- 风格照现有文件(vitest、`assert.deepStrictEqual`)。

「先读」这几步逼它接地。Codex 默认会在动手前读文件,但指名确切路径能去掉「到底哪几个文件重要」的猜测。

Step 2:喂 bug 历史

这块过去的 bug(从 `git log --grep="fix" -- src/parsers/invoice.ts`):
- 2026-01:格式是 "2024-02-29" 时闰日解析失败
- 2026-03:Unicode normalization(NFC vs NFD)导致字段名匹配失败
- 2026-04:空数组 `lineItems: []` 返回 NaN 总价而不是 0

为每条出一个回归测试。放在 `describe("regression", ...)` 块里。

已知 bug 的回归测试比 10 条假想 edge case 都值钱——因为它守的是这段代码已经暴露过的失败模式。

Step 3:要对抗式输入(顺手砍掉琐碎分支)

`parseInvoice` 能收到、但仍算合法的最坏输入是什么?
- 最长可能的字段值
- 空数组和空字符串
- Unicode surrogate pair
- 接近 JS `Number` 边界的数(`Infinity`、`Number.MAX_SAFE_INTEGER`)
- 错配的 currency / locale 组合

每条写一个测试,给出输入和期望行为。
不要为 debug 日志、fallback 字符串这类琐碎分支加测试。

最后一句直接干掉原因 6(冲覆盖率)。

Step 4:要 property-based 测试就明说

用 `fast-check` 出 3 条 property-based 测试:
- Property 1:round-trip——任意合法 InvoiceRaw,parse(serialize(x)) 深度等于 x。
- Property 2:sum 不变量——total 等于 lineItems.amount 之和。
- Property 3:currency 一致——每个 line item 都和 invoice 的 currency 一致。

每条给出 `fast-check` 的 setup 和断言。

Step 5:编不过就拒收

Codex 给完建议,落到测试文件里跑。用 Vitest:

pnpm vitest run src/parsers/invoice.test.ts --reporter=verbose

如果有测试编不过(引用了不存在的字段、或类型用错),按名字拒收:

测试 `parseInvoice handles tax: undefined` 用了 `input.tax`,但类型是 `taxes: TaxLine[]`(复数)。按真实类型重写。

指出确切的 mismatch,会训练这一整个 session 接地到真实类型,而不是继续猜。

Step 6:新函数让 Codex 先写测试

Greenfield 函数:

你即将实现 `parseInvoice`。实现**之前**:
1. 读 `InvoiceRaw` 和 `Invoice` 的类型定义。
2. 写 6 个测试,覆盖:happy path、每个 enum 分支、boundary、unicode、闰年边界、error throw。
3. 测试现在应该编不过(还没实现)——这是预期。
4. 然后实现函数,让所有测试 pass。

测试先行比测试后补能挤出更紧的测试——因为测试在实现之前就把合同定下来了,实现没法反过来带偏它。

让它固化下来:AGENTS.md 和一个可复用 prompt

上面那些步骤修的是单次会话。要不每次请求都重打一遍「读类型文件、照现有框架」,就把这些约定下沉到配置里,让每次 Codex 运行都继承它们。

把测试约定写进 AGENTS.md 截至 2026 年 6 月,Codex 在动手之前会先读 AGENTS.md 文件。它从你的全局文件(~/.codex/AGENTS.md)开始,沿 Git root 一路合并到当前目录,越靠近工作目录的文件覆盖越早的(合并到 project_doc_max_bytes 上限为止,默认 32 KiB)。加一小段测试约定:

## Testing conventions
- Test runner: Vitest. Run a single file with `pnpm vitest run <path>`.
- Assertions: `assert.deepStrictEqual` from `node:assert/strict`. Do not introduce Jest.
- Mock HTTP with `ky`'s test helpers, never `axios`.
- Group regression tests in a `describe("regression", ...)` block.
- Never add tests for debug logging or pure fallback branches just to raise coverage.

这样「照现有框架」和「别冲覆盖率」就被强制执行了,你不用每次都说。

把测试建议 prompt 存成可复用 skill。 OpenAI 已经弃用 ~/.codex/prompts 自定义 prompt,改推 skills(可被 Codex 显式或隐式调用、并随仓库分发的可复用指令)。把 Step 1 + Step 2 的模板做成一个带占位符的 skill,就能对任意函数复用,让 Codex 自己去拉 bug 历史。这样接地版 prompt 成了默认,而不是一个你得记着去打的东西。

怎么确认修好了

当建议的测试同时满足下面这几条,泛化问题就算修好了:

  1. 每个测试都指名一个具体输入和期望输出,取自真实类型——没有 any,没有编的字段。
  2. pnpm vitest run <path> 能编过并跑起来,建议里没有类型错误。
  3. 至少有一个回归测试对得上 git log --grep="fix" -- <file> 里的真实条目。
  4. 零重复——现有文件里已有的测试一个都没重出(diff 确认)。
  5. 没有测试为了覆盖率瞄准琐碎分支(log、fallback string)。

任何一条没过,说明 prompt 还是接地不够——回到 Step 1,把缺的东西指名补上。

预防建议

  • 每个测试建议 prompt 都锚到 函数签名 + 类型 + 现有测试文件。
  • 把测试约定(runner、断言风格、mock 库)写进 AGENTS.md,让 Codex 不用被告知就继承。
  • 每个区域维护一份”已知 bug 形状”笔记,在测试 prompt 里引用——避免回归 bug 再被忘掉。
  • 强制建议引用真实字段和类型;出现泛 any 或编的字段直接拒。
  • 建议立刻跑一遍,编不过真实签名的直接拒。
  • 新代码用 Codex 测试先行——比测试后补覆盖更紧。
  • 覆盖率不是目标,每条测试 catch 的 bug 数才是。只冲覆盖的测试删掉。

常见问题

我已经指名了函数,Codex 还是列泛测试,为什么? 多半是它没读支撑文件。把读这一步写明,并要它给证据:「出测试建议之前,先把函数签名和 InvoiceRaw 类型引述给我。」如果 Codex 引述不出来,就说明它根本没打开,写出来的测试都是猜的。

我该让 Codex 去冲一个覆盖率数字吗? 不该。覆盖率目标会把 Codex 推向为琐碎分支(log、fallback)加测试——抬一个百分点,抓不到任何 bug。改成要它出守护真实失败模式和已知 bug 的测试;有用的那些会顺带把覆盖率带上来。

为什么 Codex 老用错测试框架或 mock 库? 当你的 prompt 没钉死选择时,它会默认用见得最多的库(常是 Jest 和 axios)。把 runner 和 mock 库写进 AGENTS.md,框架用错的建议就不再出现了。

Codex CLI 该用哪个模型做这件事? Codex CLI 默认的 GPT-5.5 模型(自 2026 年 4 月 23 日那版起为默认)只要 prompt 指名了文件,就能把接地的测试生成做好。这里模型很少是瓶颈——欠规格的 prompt 才是。

测试先行还是测试后补,跟 Codex 配哪个好? 测试先行。在实现之前写测试,会逼 Codex 先承诺一个合同,于是测试描述的是预期行为,而不是给实现碰巧产出的东西盖橡皮章。

相关阅读

标签: #Codex #Coding Agent #排查 #排查 #测试太泛