你让 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 而不是 ky) | Codex 猜了依赖 | 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 成了默认,而不是一个你得记着去打的东西。
怎么确认修好了
当建议的测试同时满足下面这几条,泛化问题就算修好了:
- 每个测试都指名一个具体输入和期望输出,取自真实类型——没有
any,没有编的字段。 pnpm vitest run <path>能编过并跑起来,建议里没有类型错误。- 至少有一个回归测试对得上
git log --grep="fix" -- <file>里的真实条目。 - 零重复——现有文件里已有的测试一个都没重出(diff 确认)。
- 没有测试为了覆盖率瞄准琐碎分支(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 #排查 #排查 #测试太泛