实战 SOP
实战 SOP

AI 自动化测试生成 SOP:从单测到回归的人审门控流程

AI 自动化测试生成 SOP 六步:何时用+量化基线(覆盖率/缺陷逃逸)-> 从代码生单测 -> 从 issue 生回归测试 -> 边界参数化批量 -> 测试维护(过期断言/冗余)-> 人审门控+CI。每步配 pytest 与 jest 可复制代码,附 5 条踩坑(断言过松/互相依赖/伪造覆盖率/mock 漂移/不测真实路径)与 5 条 FAQ。铁律:AI 只出草稿,人审+CI 跑绿才合并。

发布于 2026年8月4日7 分钟阅读
<!-- ai-test-generation-sop | sop | AI 自动化测试生成 SOP:从单测到回归的人审门控流程 -->

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),重点看未覆盖的行号,而非总百分比:

python
# 安装: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
javascript
// 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 被测函数本身。

python
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}")
javascript
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 描述常缺关键上下文(输入数据、环境版本),喂之前要先补全,否则模型会编造一个它自圆其说的复现场景。

python
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.content
javascript
const 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 出错面收窄到数据行,而不是测试结构。

python
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)
javascript
// 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。维护比生成更重要,否则测试库会变成搜索到的文献所说的"技术债,成本反而超过收益"。

python
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}
javascript
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 草稿"转化为"可信测试"的唯一熔炉。

yaml
# .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 Noneexpect(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 测试直推主分支,短期内覆盖率曲线漂亮,长期一定是腐烂的测试库和比没测试更危险的虚假安全感。


参考来源

本文由 AI 辅助生成,经人工审核编辑。最后更新:2026-08-04

常见问题

AI 生成的测试跑不通怎么办?
别手动改成能跑。跑不通说明 prompt 或喂的上下文不够。把失败报错回贴给模型让它自修,循环 2-3 轮;还不行就补全函数签名/类型注解/调用示例再试。手动改会掩盖 prompt 缺陷,下一批还是错。
覆盖率提到多少算够?
没有万能数字。70% 是常见起步门槛(`--cov-fail-under=70`),但核心业务逻辑应奔 90%+,胶水/UI 代码 50% 也可能合理。比总覆盖率更重要的是:关键分支是否被测、未覆盖行号是否在收敛。刷高覆盖率很容易,测对才难。
哪些代码不适合 AI 生成测试?
行为强依赖外部系统(数据库、第三方 API、消息队列)且难定义预期输出的胶水代码;涉及时间/并发/随机的不确定逻辑;以及刚在频繁改动的代码——测试会随实现反复重写。这类先让人写契约测试或集成测试打底。
怎么防止 AI 造伪用例(预期值是编的)?
让 AI 只输出参数表的"输入"列,"预期"列由你跑一遍当前实现填入(前提是该实现已被 deemed 正确),再锁定为回归基线。或者让 AI 同时给出预期值的推理依据,人审逐条核对。绝不能盲信 AI 给的 expected。
人审太慢,能全自动吗?
不能。AI 测试的核心风险就是"看着绿实际没测",全自动等于把质量门禁交给一个会编断言的模型。可以自动化的是"跑通"和"覆盖率不降"两道门,断言质量必须人看。折中:AI 生成 + 自动跑通 + 人审抽查高风险模块(支付、鉴权、数据迁移)。

相关文章

实战 SOP

AI 数字人制作实战 SOP:从脚本到成品的可复制流程

把 AI 数字人制作拆成六步可复制流程:明确用途选工具(HeyGen/D-ID/Synthesia/Colossyan/DeepBrain 及国内腾讯智影/硅基智能)、写口播脚本(附 prompt 模板)、选或定制形象、先定音色再生成口型、字幕剪辑与合规后处理、平台适配发布。附 5 个避坑(形象授权/口型对不齐/多语言音色/长视频成本/合规标识)和 5 条 FAQ。代表性流程,非单一工具实测,功能以官网为准。

2026年8月7日8 分钟阅读
实战 SOP

block/buzz 自托管部署 SOP:从 Docker 到 agent 入组

block/buzz 自托管部署完整 SOP(与 buzz-hive-mind 热点文成对):本地开发栈(just setup/build/dev)+ 生产单节点(deploy/compose Docker,Postgres/Redis/MinIO)+ 配置(.env:RELAY_URL/BUZZ_RELAY_PRIVATE_KEY/RELAY_OWNER_PUBKEY)+ agent 入组(Nostr keypair NIP-98 签名,buzz-admin 管成员)+ 闭门 relay + 5 FAQ。部署命令全据 README/compose/.env/CLI/ARCHITECTURE,未编造。

2026年8月6日9 分钟阅读
实战 SOP

n8n 搭建 AI agent 工作流实战 SOP:部署与避坑

在 n8n 画布里搭一个能自主调用工具的 AI agent 工作流的完整 SOP:Docker 自托管一条命令部署、AI Agent 节点四件套解剖(Language Model+Memory+Tools+System Prompt)、分步搭建(选触发器->配节点->加工具->输出->测试发布)、五个避坑(Memory 失忆/API Key 硬编码/过度设计/上下文漂移/数据格式不匹配)+5 FAQ。节点参数以 n8n 官方文档为准,给配置逻辑不伪造完整 JSON。

2026年8月6日9 分钟阅读