AI 生成测试是当下最被低估又最易翻车的 LLM 编码场景。让模型照着函数吐 20 条 pytest 用例,几分钟就出,覆盖率数字立刻好看——但"绿条"不等于"测对了"。AI 最常见的失败是断言松到形同虚设、mock 漂移到与真实接口脱节、用例互相依赖导致删一条全红。本站《AI 代码重构实战 SOP》(ai-code-refactoring-sop) 讲的是"改代码",这篇讲"验证代码",两者共用一个前提:先量化,再动手。这套 SOP 把 AI 测试生成拆成六步:何时用 + 量化基线 -> 从代码生单测 -> 从 issue 生回归 -> 边界参数化批量 -> 测试维护 -> 人审门控 + CI,每步给可复制的 Python(pytest) 与 JS(jest) 代码,附踩坑和 FAQ。核心一条铁律:AI 只出草稿,人审通过且 CI 跑绿才合并。通用 prompt 工程可参考本站《通用编码 Prompt 包》(prompt-coding-pack)。
一、何时用 AI 生成测试 + 量化基线
不是所有模块都值得让 AI 生成测试。先量两个数:当前覆盖率、缺陷逃逸率。覆盖率看哪些模块低、哪些分支没走到;缺陷逃逸率(线上 bug 数 / 总 bug 数)看测试到底拦没拦住,如果线下测了一堆但线上 bug 还是源源不断,说明测试在测错地方。两个数要一起看:覆盖率高高在上但逃逸率也高,等于测了一堆无关路径;都低,说明测试基本没起步。AI 生成测试适合"实现稳定、边界清晰、用例可枚举"的纯函数和工具函数;不适合"行为依赖外部系统、断言难定义"的胶水代码——后者人写都难,AI 更易造伪用例。
量化基线用 coverage.py(Python)和 jest 内置 coverage(JS),重点看未覆盖的行号,而非总百分比:
# 安装:pip install pytest pytest-cov
# 跑覆盖率:pytest --cov=yourpkg --cov-report=term-missing
# 输出列出每个文件未覆盖的行号,AI 生成测试就冲着这些行去
import subprocess, re
def coverage_by_module(pkg: str) -> dict:
"""跑 pytest-cov 并解析每个模块的语句覆盖率与未覆盖行号"""
out = subprocess.run(
["pytest", f"--cov={pkg}", "--cov-report=term-missing", "-q"],
capture_output=True, text=True,
)
rows = {}
for line in out.stdout.splitlines():
# 形如 "src/utils.py 42 8 81% 12-15, 30"
m = re.match(r"^(\S+\.py)\s+(\d+)\s+(\d+)\s+(\d+)%\s*(.*)$", line)
if m:
path, stmts, miss, pct, missing = m.groups()
rows[path] = {
"statements": int(stmts),
"missing_lines": int(miss),
"percent": int(pct),
"missing": missing.strip(),
}
return rows// package.json 脚本:"test": "jest --coverage --collectCoverageFrom='src/**/*.{js,ts}'"
// 跑:npm test -- --coverage
// 输出表格列出每文件 % Stmts / % Branch / Uncovered Lines
// 解析 jest 的 coverage-summary.json 做基线快照
const fs = require('fs');
function coverageBaseline(reportPath = 'coverage/coverage-summary.json') {
const summary = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const rows = {};
for (const [file, m] of Object.entries(summary)) {
rows[file] = {
lines: m.lines.pct,
branches: m.branches.pct,
uncovered: m.lines.pct < 100,
};
}
return rows;
}
// 基线快照对比:AI 生成测试后,未覆盖行号应真正减少先记基线,AI 生成测试后再跑一次,对比未覆盖行号是否真减少。如果覆盖率涨了但未覆盖行号没变——说明 AI 在测已有覆盖的路径,纯刷数字。
二、从现有代码生成单测
喂给 LLM 的不是"写测试"三个字,而是函数签名 + 完整实现 + 明确的用例要求(正常/边界/异常)。要求模型按 given-when-then 结构输出,并显式禁止"非空即过"的松断言、禁止 mock 被测函数本身。
import ast
from openai import OpenAI
client = OpenAI() # OpenAI 兼容接口;模型名以官网为准
GEN_UNIT_PROMPT = """你是测试工程师。下面是待测函数的源码。请用 pytest 生成单元测试,要求:
1. 覆盖正常路径、边界值(空、零、负、最大值)、异常输入;
2. 每条测试用 given-when-then 注释说明意图;
3. 断言必须检查具体返回值或抛出的异常类型,禁止只写 assert result is not None;
4. 不要 mock 被测函数本身。只输出代码,不要解释。
源码:
{code}
"""
def gen_unit_tests(source_path: str, func_name: str) -> str:
"""读取源码中指定函数,生成 pytest 用例草稿"""
src = open(source_path, encoding="utf-8").read()
tree = ast.parse(src)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == func_name:
seg = ast.get_source_segment(src, node)
resp = client.chat.completions.create(
model="qwen-plus", # 以官网为准
messages=[{"role": "user",
"content": GEN_UNIT_PROMPT.format(code=seg)}],
temperature=0.2,
)
return resp.choices[0].message.content
raise ValueError(f"未找到函数 {func_name}")const fs = require('fs');
const { OpenAI } = require('openai'); // npm i openai;模型名以官网为准
const client = new OpenAI();
const GEN_UNIT_PROMPT = `你是测试工程师。下面是待测函数的源码。请用 Jest 生成单元测试,要求:
1. 覆盖正常路径、边界值(空、零、负、最大值)、异常输入;
2. 每条测试用 given-when-then 注释说明意图;
3. 断言必须检查具体返回值或抛出的异常类型,禁止只写 expect(x).toBeDefined();
4. 不要 mock 被测函数本身。只输出代码。
源码:
{code}
`;
async function genUnitTests(sourcePath, funcName) {
const src = fs.readFileSync(sourcePath, 'utf8');
// 正则粗提取函数(生产环境建议用 @babel/parser 做 AST 提取)
const re = new RegExp(
`(?:export\\s+)?(?:async\\s+)?function\\s+${funcName}[\\s\\S]*?^\\}`, 'm');
const match = src.match(re);
if (!match) throw new Error(`未找到函数 ${funcName}`);
const resp = await client.chat.completions.create({
model: 'qwen-plus', // 以官网为准
messages: [{ role: 'user',
content: GEN_UNIT_PROMPT.replace('{code}', match[0]) }],
temperature: 0.2,
});
return resp.choices[0].message.content;
}生成后立刻本地跑一遍,跑不通的把报错回贴给模型让它自修,循环 2-3 轮——而不是手动改。手动改会掩盖 prompt 缺陷,下一批还是错。这个"生成->跑->回贴报错->再生成"循环可用 LangChain 等 prompt chaining 框架编排,但核心是失败回贴让模型自修。
三、从 issue/PR 描述生成回归测试
线上 bug 复现是最该自动化的测试。把 issue 描述(含复现步骤、预期/实际)喂给模型,要求它先生成一个能复现 bug 的失败测试,再确认修复后该测试转绿。相比从零写测试,回归测试有天然锚点:issue 里写明了"实际发生了什么",模型只需把它翻译成断言。但 issue 描述常缺关键上下文(输入数据、环境版本),喂之前要先补全,否则模型会编造一个它自圆其说的复现场景。
REGRESSION_PROMPT = """这是一个 GitHub issue。请用 pytest 写一条回归测试:
1. 先按 issue 描述的复现步骤构造能触发 bug 的输入;
2. 该测试在修复前应当失败,修复后应当通过;
3. 用 pytest.raises 或显式断言锁定预期行为;
4. 在测试名和注释里标注 issue 编号。
issue #{number}:
{body}
"""
def gen_regression_test(number: int, body: str) -> str:
resp = client.chat.completions.create(
model="qwen-plus", # 以官网为准
messages=[{"role": "user",
"content": REGRESSION_PROMPT.format(number=number, body=body)}],
temperature=0.2,
)
return resp.choices[0].message.contentconst REGRESSION_PROMPT = `这是一个 GitHub issue。请用 Jest 写一条回归测试:
1. 先按 issue 描述的复现步骤构造能触发 bug 的输入;
2. 该测试在修复前应当失败,修复后应当通过;
3. 用 expect(() => fn(...)).toThrow(...) 或显式断言锁定预期行为;
4. 在测试名和注释里标注 issue 编号。
issue #{number}:
{body}
`;
async function genRegressionTest(number, body) {
const resp = await client.chat.completions.create({
model: 'qwen-plus', // 以官网为准
messages: [{ role: 'user',
content: REGRESSION_PROMPT
.replace('{number}', number).replace('{body}', body) }],
temperature: 0.2,
});
return resp.choices[0].message.content;
}关键纪律:先在未修复分支上跑这条测试确认它红,再合修复确认它绿。如果"未修复也绿",说明测试根本没复现 bug,是假回归测试,直接丢。
四、边界与参数化用例批量生成
边界用例最适合参数化:一组输入一个表,一条测试函数跑全部。让 AI 只负责生成参数表(输入 + 预期),你套用固定的参数化模板——这样 AI 出错面收窄到数据行,而不是测试结构。
import pytest
# AI 只产出这张表,人审后套进参数化模板
PARAMS = [
("", 0), # 空串
("abc", 3), # 正常
("a" * 1000, 1000), # 长串
(None, TypeError), # 非法类型
("中文测试", 4), # 多字节
]
@pytest.mark.parametrize("s,expected", PARAMS,
ids=["empty", "normal", "long", "invalid", "multibyte"])
def test_len_safe(s, expected):
"""带类型校验的安全长度函数"""
if s is None:
with pytest.raises(TypeError):
len_safe(s)
else:
assert len_safe(s) == expected
def len_safe(s):
if s is None:
raise TypeError("input must be a string")
return len(s)// Jest 参数化:test.each 套用 AI 生成的表
const PARAMS = [
{ input: '', expected: 0, id: 'empty' },
{ input: 'abc', expected: 3, id: 'normal' },
{ input: 'a'.repeat(1000), expected: 1000, id: 'long' },
{ input: null, expected: 'throw', id: 'invalid' },
{ input: '中文测试', expected: 4, id: 'multibyte' },
];
test.each(PARAMS)('lenSafe($id) -> $expected', ({ input, expected }) => {
if (expected === 'throw') {
expect(() => lenSafe(input)).toThrow(TypeError);
} else {
expect(lenSafe(input)).toBe(expected);
}
});
function lenSafe(s) {
if (s === null || s === undefined) {
throw new TypeError('input must be a string');
}
return s.length;
}让 AI 输出 JSON 格式的参数表(而非整段测试代码),人审逐行核对预期值后再套模板,可把 AI 编造断言的风险降到最低——它只能编数据,编不了测试骨架。
五、测试维护:检测过期断言与删冗余
测试会腐烂。AI 一次生成几十条,半年后有的断言已过时(依赖的实现变了)、有的互相重复、有的永远 assert True。维护比生成更重要,否则测试库会变成搜索到的文献所说的"技术债,成本反而超过收益"。
import ast, re
def find_smell_tests(test_path: str) -> dict:
"""扫描 pytest 文件,定位三类测试坏味"""
src = open(test_path, encoding="utf-8").read()
tree = ast.parse(src)
always_pass, tautology, names = [], [], []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
body_src = ast.get_source_segment(src, node)
names.append(node.name)
# 坏味1:只 assert True / assert 1
if re.search(r"assert\s+(True|1)\b", body_src):
always_pass.append(node.name)
# 坏味2:恒真断言 assert x == x(带捕获组做反向引用)
if re.search(r"assert\s+(\w+)\s*==\s*\1\b", body_src):
tautology.append(node.name)
# 坏味3:同名测试(pytest 只跑一个,其余静默丢失)
seen, dups = set(), []
for n in names:
if n in seen:
dups.append(n)
seen.add(n)
return {"always_pass": always_pass,
"tautology": tautology, "duplicates": dups}const fs = require('fs');
function findSmellTests(testPath) {
const src = fs.readFileSync(testPath, 'utf8');
const smells = { noAssertions: [], tautology: [], duplicateNames: [] };
const names = new Set();
// 正则粗扫(精确分析建议用 @babel/parser 或 ts-morph)
const testBlocks = src.match(/(?:test|it)\(['"`](.+?)['"`][\s\S]*?\}\);/g) || [];
for (const block of testBlocks) {
const nameMatch = block.match(/(?:test|it)\(['"`](.+?)['"`]/);
const name = nameMatch ? nameMatch[1] : '<anon>';
if (names.has(name)) smells.duplicateNames.push(name);
names.add(name);
if (!/expect\(/.test(block)) smells.noAssertions.push(name);
// 恒真断言 expect(x).toBe(x)(带捕获组)
if (/expect\(([^)]+)\)\.toBe\(\1\)/.test(block)) smells.tautology.push(name);
}
return smells;
}检测出坏味后可让 AI 重新生成这些测试,但必须人审——AI 删冗余时容易把"看着重复、实则测不同分支"的用例一起删掉。
六、人审门控 + CI 集成
AI 测试草稿进入主分支的唯一通道:人审 + CI 双门控。CI 必须跑通所有测试且覆盖率不降;人审必须看断言质量而非数量。两道门缺一不可:只靠 CI,绿条会放过所有"能跑但没测"的废用例;只靠人审,疲劳的审阅者迟早漏掉一条互斥分支。门控不是流程负担,而是把"AI 草稿"转化为"可信测试"的唯一熔炉。
# .github/workflows/ai-tests.yml — AI 测试门控 CI
name: ai-test-gate
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install pytest pytest-cov
# 全量测试 + 覆盖率门槛,失败即拦截合并
- run: pytest --cov=src --cov-fail-under=70 -q
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm test -- --coverage人审清单(贴在 PR 模板里):
- 每条断言检查具体值或异常类型,无
is not None/toBeDefined()充数 - 用例之间无隐式依赖(可乱序单跑)
- mock 的是外部依赖,不是被测函数本身
- 回归测试在未修复分支确认真的红过
- 覆盖率提升来自未覆盖行号减少,非刷重复路径
七、踩坑记录
坑一:AI 断言过松。 模型爱写 assert result is not None、expect(x).toBeTruthy()——这类断言几乎任何返回值都能过,等于没测。人审第一关就是把所有"非空即过"的断言改成具体值比对。
坑二:测试互相依赖。 AI 生成时若共享全局状态或依赖执行顺序,删一条会连锁失败。pytest 用 fixture 隔离、jest 每条 test 前重置状态,并在 CI 里用随机顺序跑(如 pytest-randomly 插件)暴露隐式依赖。
坑三:伪造覆盖率。 AI 大量测 trivial getter/setter 和已覆盖路径,覆盖率数字涨但未覆盖行号没动。盯 --cov-report=term-missing 的具体行号,而非总百分比。
坑四:mock 漂移。 AI 按它想象的接口写 mock,与真实 API 字段名/返回结构对不上,测试绿但生产挂。定期跑"去 mock 集成测试"对照真实接口,或用 contract test 锁定 mock 形状。
坑五:不测真实路径。 AI 把被测函数内部调用的依赖全 mock 掉,结果测的只是 mock 不是逻辑——这正是社区所称的"mocked confidence"。规则:mock 只用于外部 I/O(网络/磁盘/时钟),被测函数自身的分支必须走真实代码。
坑六:忽视 flaky 测试。 AI 生成的测试有时因依赖执行顺序、系统时间或随机种子而时过时不过,团队常本能地 @pytest.mark.skip 或加重试掩盖。但 flaky 是信号不是噪音,每一条都指向一个隐式依赖或不确定行为,必须修根因(固定时间、隔离状态、锁定随机种子)而非跳过。跳过的 flaky 测试等于没有测试,还给人"已覆盖"的错觉。
FAQ
Q1:AI 生成的测试跑不通怎么办?
别手动改成能跑。跑不通说明 prompt 或喂的上下文不够。把失败报错回贴给模型让它自修,循环 2-3 轮;还不行就补全函数签名/类型注解/调用示例再试。手动改会掩盖 prompt 缺陷,下一批还是错。
Q2:覆盖率提到多少算够?
没有万能数字。70% 是常见起步门槛(--cov-fail-under=70),但核心业务逻辑应奔 90%+,胶水/UI 代码 50% 也可能合理。比总覆盖率更重要的是:关键分支是否被测、未覆盖行号是否在收敛。刷高覆盖率很容易,测对才难。
Q3:哪些代码不适合 AI 生成测试?
行为强依赖外部系统(数据库、第三方 API、消息队列)且难定义预期输出的胶水代码;涉及时间/并发/随机的不确定逻辑;以及刚在频繁改动的代码——测试会随实现反复重写。这类先让人写契约测试或集成测试打底。
Q4:怎么防止 AI 造伪用例(预期值是编的)?
让 AI 只输出参数表的"输入"列,"预期"列由你跑一遍当前实现填入(前提是该实现已被 deemed 正确),再锁定为回归基线。或者让 AI 同时给出预期值的推理依据,人审逐条核对。绝不能盲信 AI 给的 expected。
Q5:人审太慢,能全自动吗?
不能。AI 测试的核心风险就是"看着绿实际没测",全自动等于把质量门禁交给一个会编断言的模型。可以自动化的是"跑通"和"覆盖率不降"两道门,断言质量必须人看。折中:AI 生成 + 自动跑通 + 人审抽查高风险模块(支付、鉴权、数据迁移)。
看法
AI 测试生成的真正价值不是"少写测试",而是"把测试从奢侈品变成日用品"。当一个纯函数有 12 个边界组合,人会因为懒只写 3 条,AI 能在 30 秒内铺满 12 条参数化用例——前提是你用参数化模板套住它,只让它产出数据而非代码。判断 AI 测试是否真的有用的试金石只有一个:删掉被测函数里的一行业务逻辑,测试是否变红。如果删了逻辑测试还绿,说明它从没测过这段逻辑,只是凑数的覆盖率。但这套流程的安全阀始终是人审门控:AI 草稿必须人审 + CI 跑绿才合并,这条铁律一个字都不能松。本站《AI 代码重构实战 SOP》讲重构、这篇讲验证,两者闭环——重构让你敢改,测试让你敢重构。绕过人审把 AI 测试直推主分支,短期内覆盖率曲线漂亮,长期一定是腐烂的测试库和比没测试更危险的虚假安全感。
参考来源