# 让 AI 写测试：期望值从哪里来，以及怎么确认这些测试真的会拦住错误

从自己写验收表、只交表不交实现，到用变异检查撞坏代码、认出三种坏测试，一套确认测试可不可信的动作

> AI 编程协作 · 单元测试 · 测试有效性 · 约 8 分钟 · 10 月 21 日

## 本篇要点

1. 全绿的测试可能什么都没证明：它要么真的在核对行为，要么本来就不可能红。
2. 测试的期望值只能来自验收标准；把实现一起交给模型，它就把现有行为（包括 bug）抄进断言——给正确代码时测试准确率 80.45%、能检出 39.85% 的 bug，换成有 bug 的代码后分别降到 57.12% 和 19.61%。
3. 先写测试、要求“现在必须红”，再要求它只改实现不改断言，可以避免把 bug 固化成预期行为。
4. 覆盖率不能当判据：只调用方法不做断言也能拿满覆盖率和通过率，而 100% 行覆盖的套件变异分数可能只有 4%。
5. 判断测试可不可信最直接的办法是变异检查：亲手改坏几处代码，看有没有测试变红；没变红的地方只是被执行过，没有被核对。
6. “换一种写法实现同样行为，这条测试还该不该通过”能筛掉绑死实现的断言。
7. 没有断言的测试、一条断言对应不上验收标准一行的测试，在 LLM 生成结果里极为普遍；异常场景几乎普遍缺失。

---

上一篇结束在一个报错循环里。这次换个开场：你不贴报错，只说“帮我给这段代码写点测试”。几分钟后拿到一个 `test_export.py`，跑一下，全绿。

全绿看着像验收通过，其实什么都没证明。绿色有两种来源：代码真的对了；或者这些测试本来就不可能红。后者更常见，也更危险，因为它把“没检查”伪装成了“检查过了”。

这篇要你亲手做一件事：让 AI 写一组测试，再用三种方式证明这组测试真的会拦住错误。

**开工前准备三样**：一个能跑 `pytest` 的目录，里面有个可 import 的模块（下面用 `export.py`）；一份你自己写过的验收标准（没有就从上一篇需求的“完成标准”“出错表现”两栏抄）；一个干净的 git 工作区——后面要反复改坏代码再还原。

## 第 0 步：先把验收标准写成表

测试里唯一有价值的东西是“应该等于什么”，这部分叫测试的**期望值**（test oracle）。AI 最容易糊弄人的地方也在这里：它写得出格式漂亮、断言具体的测试，但断言的期望值是从你现有代码里抄来的。抄来的期望值只会把当前行为（包括 bug）固定下来。

所以期望值必须由你先给，来源只能是需求。接着上一篇那个解析金额的函数：

| 输入 | 期望 |
|---|---|
| `79.00` | `79.0` |
| `1,299.00` | `1299.0` |
| `N/A` | `None` |
| `""` | `None` |
| `-5.00` | `-5.0`（退款是合法负数） |
| `1.299,00` | 抛 `ValueError`（欧陆格式，我们不接受） |

最后一行是上一篇埋的伏笔：`1.299,00` 去掉逗号会变成 `1.29900`，`float` 高高兴兴接受，总额悄悄错掉。这种“不报错但结果错”的问题，正是测试唯一能长期看住的东西。

## 第 1 步：把表交给它，但别把实现交给它

为了让 AI“写得更贴合”，很多人会把 `export.py` 一起贴进去。看起来是给上下文，实际是给答案。

有研究量过代价：提示里给正确代码时，模型生成的测试在 HumanEval 上准确率 80.45%，换成有 bug 的代码掉到 57.12%；在 APPS 上，给正确代码时测试能检出 39.85% 的 bug，给错代码只剩 19.61% [1]。另一项实验结论一致：模型生成的断言主要捕捉**实际行为**，而不是**期望行为**，而且它判断别人给的断言对不对的能力比生成断言还差 [2]。

原理不复杂：期望值要么来自你写的需求，要么来自它读到的代码。你把代码递过去，等于让它照抄现状，包括 bug。

第一次请求照这个形状写：

> 附件是一张验收表，六行输入和期望。**先只写 pytest 测试，不要写实现。**
> 1. 一条测试只验表里一行，函数名说明是哪种情况，上方注释标出它对应表里第几行。表里没有的情况标成“待确认”，不要自己加。
> 2. 断言值照表写，不要用 `assert result is not None` 这种不判具体值的写法。
> 3. 不要断言日志文本、不要断言私有函数被调用、不要断言内部数据结构。
> 4. 每条测试现在都应该跑不过——如果现在就能通过，说明它没在测需求。

第 4 条给测试定了一个可检验的条件：**现在必须红。**

## 第 2 步：先跑一遍，确认它红在对的地方

```
pytest -q
```

你要看到的失败是 `AssertionError` 或 `ValueError`，而且落在断言那一行：

```
FAILED test_export.py::test_thousands_separator
FAILED test_export.py::test_missing_value
```

两种跑偏要当场修，别往后拖：

- **`ImportError`**：连函数都还没写。先让它补一个最小签名（`def parse_amount(text): raise NotImplementedError`）再继续，这类失败会掩盖真正的问题。
- **全绿**：第一个警报。要么表里的行为已经实现，要么这批断言是照着现有代码抄的。后者就是“永远通过”的测试：以后永远绿，也永远拦不住任何东西。

## 第 3 步：让它实现，但在提示里堵住一条路

正常让它写实现，但必须加一句：

> 只改 `export.py`，不要修改测试文件和任何断言。如果你认为表里某一行写错了，停下来说明哪一行、为什么，等我确认。

不写这句，它十有八九会顺手把测试改成能过的样子：把 `1,299.00` 那一行的期望改成 `None`，或者给失败用例加个 `pytest.skip`。这不是模型格外狡猾，而是“让测试变绿”本身就是它被优化的方向。有研究评测了几款基于 LLM 的测试生成工具，发现那些会过滤或“修好”失败测试的工具，最终交付的套件反而更容易把 bug 当作正确行为验证通过 [3]。

这条和上一篇守的界线是同一条：**报错消失不等于问题解决，测试变绿不等于行为正确。**

## 第 4 步：亲手改坏代码，看它拦不拦

全绿了。但前面的绿色只说明“代码符合测试”，不说明“测试能发现错误”。把这两件事分开检查的方法是：**故意把代码改坏一处，看有没有测试变红。**

这个动作叫变异检查：往代码里注入一个人为的错误，如果测试无法区分坏版本和原版本，说明它没在核对这块行为。工程上它有成套工具和指标，在你手上手工做五个就够——每改一处跑一次 `pytest -q`，记下哪条红，然后 `git checkout -- export.py` 还原：

| 把实现改坏成 | 应该有测试变红 |
|---|---|
| `float(text.replace(",", ""))` → 再多去掉一个 `.` | `test_thousands_separator` |
| `N/A` 分支 `return None` → `return 0.0` | `test_missing_value` |
| 去掉负号处理，负数直接抛错 | `test_negative_refund` |
| `1.299,00` 从抛 `ValueError` 改成返回 `None` | `test_european_format_rejected` |
| 某个边界 `<` 改成 `<=` | 看情况——这一条常常**没有**测试变红 |

最后一行不是凑数。边界上的一个等号，是 AI 写代码时最容易出错、也最容易被测试漏掉的地方。改完毫无反应，你就找到一个真实的漏洞：那里没有被核对，只是被执行过。

**为什么不能看覆盖率。** 覆盖率只统计“这些行被执行过”，不统计“执行结果被检查过”。一项评测直接点出了这个漏洞：模型可以调用类里所有方法却什么都不检查，照样拿到 100% 覆盖率和 100% 通过率 [4]。另一项研究给出了更刺眼的对照：模型生成的测试在某个题目上达到 100% 的行覆盖和分支覆盖，对应的变异分数只有 4% [5]。

## 第 5 步：读一遍测试，认出三种坏味道

变异检查能发现“漏测”，读代码能发现“测错了对象”。拿到测试先人工扫这三种：

**没有断言的测试。** 只有调用，或者只写 `assert result is not None`。它永远通过，因为没有任何东西能让它失败。换成表里那一行的具体期望值，换不了就删掉。

**一个测试里一串断言，还说不清谁是谁。** 失败时你只知道这个测试挂了，不知道挂在哪一条，也不知道它对应哪条验收标准。统计上它在 LLM 生成的测试里极其普遍，class 级 38–49%，method 级 92.8–99% [6]。它不必然错，但你必须能把它拆回“一条断言对应验收表一行”。

**断言绑在实现上。** 断言 mock 被调用几次、断言私有函数被调用、断言日志文本、断言内部列表的顺序。判断只用一句话：**换一种写法实现同样的行为，这条测试还该不该通过？** 不该通过的，它测的是实现，不是需求。代价不在今天，而在三个月后：行为没变、你重构代码，它全红，于是你被迫改测试——改着改着就变成“改测试让 CI 变绿”，又绕回第 3 步的坑。

顺带检查异常场景。研究里 LLM 生成的测试覆盖异常处理的比例，class 级只有 7–23%，method 级几乎为 0 [6]。回到你的表，`1.299,00` 那一行必须是 `pytest.raises(ValueError)`，而不是一个随便测测的通过用例。

## 第 6 步：把漏掉的那几处喂回去

第 4 步里没变红的改动，是最好的提示词素材，比“再加强一下测试”具体一百倍：

> 我把 `1.299,00` 的分支从抛 `ValueError` 改成返回 `None`，测试全绿。补一条能区分这两种行为的测试，只改测试文件。

然后回到第 4 步再撞一次。这个“生成—撞坏—反馈—补测”的循环在研究中明显提高了测试的判别力，同一批题目上的变异分数到 89% 左右，远高于纯提示 [5]。

## 什么时候不必这么折腾

一次性脚本、还在摸索写法的原型，不值得走完整套流程。判断标准不是代码长短，而是**这段逻辑会不会再跑一次、会不会算钱、会不会决定谁能看到什么**。会，就留测试。

还有一条边界：测试本身也会腐烂。绑死实现的断言会在下次重构时逼你改测试，为了迁就旧测试而保留的错误设计也会一直留着。所以第 5 步那句判据值得反复用：它测的是行为，还是这一版写法。

测试之所以值得这些功夫，是因为它是整条协作流程里唯一能由机器替你反复核对的部分。它成立的前提只有两个：期望值来自需求，而且你真的试过把它撞坏。

## 术语表

- 测试的期望值（test oracle）：测试里“应该等于什么”的那部分。只有它来自需求而不是来自当前代码，测试才能发现错误。
- 变异检查：故意把实现改坏一处，看有没有测试变红。没红说明这块行为没被核对，只是被执行过。
- 覆盖率：测试执行到了多少行代码。它衡量“跑到了”，不衡量“做出了判断”，所以能靠空调用刷高。
- 无断言测试：调用了函数却没断言具体结果，或只断言“不是 None”，这类测试永远通过。
- 绑死实现的断言：断言的对象是内部写法（私有函数、调用次数、日志文本、内部顺序），行为不变而写法一变就红。

## 来源

1. [Rethinking the Influence of Source Code on Test Case Generation — 提示里给有 bug 的代码会降低生成测试的准确率与发现 bug 的能力](https://arxiv.org/html/2409.09464v1)
2. [Do LLMs generate test oracles that capture the actual or the expected program behaviour? — 模型生成的断言主要捕捉实际行为而非期望行为](https://arxiv.org/html/2410.21136)
3. [Design choices made by LLM-based test generators prevent them from finding bugs — 会过滤或“修好”失败测试的工具更容易把 bug 验证为正确行为](https://arxiv.org/html/2412.14137)
4. [TestGenEval — 覆盖率与通过率可被“调用所有方法但不做检查”刷满，因此同时报告变异分数](https://arxiv.org/html/2410.00752v1)
5. [Mutation-Guided Unit Test Generation with a Large Language Model — 100% 行/分支覆盖但变异分数仅 4% 的对照，以及变异引导迭代的提升](https://arxiv.org/html/2506.02954v5)
6. [On the Diffusion of Test Smells in LLM-Generated Unit Tests — Assertion Roulette、Unknown Test 与异常处理缺失的普遍程度统计](https://arxiv.org/html/2410.10628)

---

原文：https://pangzhengboyin.com/articles/ai-written-tests-oracle-and-mutation-check-79424d12

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
