AI 辅助单元测试与测试生成

单元测试是软件质量的根基,却长期是开发者最不愿手写的部分:它模板化、重复、枯燥,又容易因为”被测代码在变”而迅速腐化。AI 编程工具(GitHub Copilot、Cursor、以及各类基于 Claude/GPT 的编码 Agent)恰好擅长这类结构化产出。本文聚焦一个具体命题:如何把 AI 用在单元测试的”生成、补全、边界发现、CI 化”上,同时看清它制造的假象与局限。

为什么测试是 AI 编程的高价值场景

测试之所以特别适合交给 AI,根子在它的”结构可预测、目标可验证”:

  • 高度模板化:一个 pytest 用例大多遵循”构造输入—调用被测函数—断言输出”三段式,连命名(test_<行为>_<场景>)都有约定。模板化意味着 AI 的”补全”成功率高、稳定。
  • AI 擅长补全而非创造:写测试不需要发明新架构,只需把已有函数的行为用断言描述出来。这正好落在模型擅长的”在给定上下文里续写”区间,比”从零设计系统”可靠得多。
  • 覆盖率可被客观度量:测试好不好,不靠主观感觉,靠覆盖率、靠用例能否在代码出错时变红。可量化让 AI 的产出进入”生成—运行—反馈—再生成”的闭环,这是 AI 工作流最舒服的形态。
  • 反馈即上下文:测试运行失败会直接给出 traceback,把这段输出回灌给 AI,它就能自我修正——这是”人在回路”里成本最低的一环。

需要明确的是:AI 能显著降低写测试的边际成本,但”AI 把覆盖率提升 X%“这类精确数字取决于被测代码复杂度、基线覆盖率、团队规范,不能一概而论,本文不给出具体提升百分比。

覆盖率驱动的生成策略

让 AI 生成测试的最大误区,是一句话丢过去:“给我写测试”。结果是它只覆盖 happy path,且断言很弱。更有效的做法是覆盖率驱动:先让 AI 读被测代码,再按分支与边界去生成用例。

给 AI 足够的上下文

AI 生成测试的质量,几乎正比于你喂给它的上下文。至少提供三类信息:

  1. 被测函数本体:完整源码,包括签名、类型注解、依赖的私有辅助函数。
  2. 外部依赖清单:它调了数据库、HTTP、时间、随机数、文件系统吗?这决定要不要 mock。
  3. 项目约定:你用 pytest 还是 unittest?断言风格?目录结构?fixture 写法?把一份现有测试样本贴给它,比长篇描述约定更有效。

按分支与边界拆解

不要只说”覆盖所有分支”,而是明确要求它列出分支并逐一对应用例。例如让 AI 先输出”测试清单”再写代码:

阅读 src/parser.py 的 parse_config,列出所有 if/else、循环、提前 return 的分支,
以及每个分支的边界条件(空串、超长、非法字符、None)。
先输出一份"分支→用例"映射表,待我确认后再写 pytest 代码。

这种”先列计划、再写代码”的约束,能显著减少它漏掉分支的概率。下面的例子演示让 AI 基于一个解析函数生成用例(被测代码示意):

# src/parser.py
def parse_config(text: str) -> dict:
    if not text:
        raise ValueError("empty config")
    lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    result = {}
    for ln in lines:
        if "=" not in ln:
            raise ValueError(f"bad line: {ln}")
        key, val = ln.split("=", 1)
        result[key.strip()] = val.strip()
    return result
# tests/test_parser.py
import pytest
from src.parser import parse_config


def test_parse_normal():
    cfg = parse_config("host=localhost\nport=8080")
    assert cfg == {"host": "localhost", "port": "8080"}


def test_parse_empty_raises():
    with pytest.raises(ValueError):
        parse_config("")


def test_parse_missing_equal_raises():
    with pytest.raises(ValueError):
        parse_config("notakey")


def test_parse_ignores_blank_lines():
    cfg = parse_config("\n\na=1\n\n")
    assert cfg == {"a": "1"}

注意 test_parse_ignores_blank_lines 这类”看似琐碎”的用例,正是 AI 容易漏、人也不爱写、却最容易藏 bug 的地方。覆盖率工具(如 Coverage.py)能告诉你哪一行没被命中,把报告贴回 AI,让它补对应用例,形成闭环。

Mock/Stub 自动构造

真实代码很少是纯函数。它要查库、打网络、读时间、写文件。测试这些代码若不隔离依赖,会慢、会不稳定、会污染数据。Mock/Stub 的作用就是用可控的替身替换真实依赖。AI 在这类机械且模板化的代码上尤其高效。

用 AI 生成 Mock 的常见模式

以 Python 为例,让 AI 基于依赖构造 unittest.mock

# src/service.py
import requests


def fetch_user_name(user_id: int) -> str:
    resp = requests.get(f"/api/users/{user_id}")
    resp.raise_for_status()
    return resp.json()["name"]
# tests/test_service.py
from unittest import mock
import pytest
from src.service import fetch_user_name


def test_fetch_user_name_success():
    fake_resp = mock.Mock()
    fake_resp.raise_for_status.return_value = None
    fake_resp.json.return_value = {"name": "Alice"}

    with mock.patch("src.service.requests.get", return_value=fake_resp):
        assert fetch_user_name(1) == "Alice"


def test_fetch_user_name_http_error():
    fake_resp = mock.Mock()
    fake_resp.raise_for_status.side_effect = Exception("404")

    with mock.patch("src.service.requests.get", return_value=fake_resp):
        with pytest.raises(Exception):
            fetch_user_name(999)

unittest.mock.patchrequests.get 替换成替身,并设置其返回值与异常,从而让测试不触网、可重复、毫秒级。时间依赖可用类似方式冻结:Python 的 freezegun 或手动 mock datetime.now

JavaScript 生态里,Vitest 的 vi.mock 与 Jest 的 jest.mock 同理:

// 用 Vitest 隔离一个外部模块
import { describe, it, expect, vi } from "vitest";
import { getUser } from "../src/user";

vi.mock("../src/api", () => ({
  fetchUser: vi.fn(),
}));

import { fetchUser } from "../src/api";

describe("getUser", () => {
  it("returns name from api", async () => {
    fetchUser.mockResolvedValue({ name: "Bob" });
    expect(await getUser(1)).toBe("Bob");
  });
});

Mock 的陷阱(务必提示 AI 规避)

  • Mock 了错误的对象路径patch("requests.get") 无效,必须 patch 被测模块里实际被引用名字(上例是 src.service.requests.get)。这是 AI 常犯、且测试”假绿”的根源。
  • Mock 过厚,等于没测:把被测逻辑整个替身化,测试只验证了”mock 被正确调用”,没验证行为。要 mock 依赖、而非被测函数本身。
  • 断言停留在”被调用过”:只 assert mock.called 太弱。应断言调用参数(mock.call_args)与返回后的业务分支。
  • 依赖替身实现的细节:过度耦合 mock 的内部行为,被测代码一重构,测试就碎。优先测可观测的输出。

发现边界与异常路径

AI 默认倾向 happy path,因为训练语料里”正常用法”远多于”异常用法”。要它覆盖边界,必须显式点名

需要重点提示的边界维度

  • 空与 None:空字符串、空列表、None、空字典。
  • 超长与超大:超长输入、超大数字、超出缓冲区。
  • 非法与越界:负值、非预期类型、越界索引、非法字符。
  • 并发与时序:同一资源被并发修改、回调乱序、超时。
  • 异常分支:每个 except、每个提前 return、每个 raise

给 AI 的提示要像一份检查单:

为该函数写测试,必须包含以下边界:空输入、None、超长字符串(>10000)、负数、
非法类型(str 当 int)、重复调用、并发 10 次调用。
每条用例用一句话说明它在验证哪条行为。断言必须是强断言(验证返回值/副作用,而非仅验证不抛错)。

让 AI 扮演”对抗者”

比”请写边界测试”更有效的是让 AI 反过来找漏洞:

你现在是测试破坏者。请找出 parse_config 在被测实现里最可能被忽略的 5 个失败点,
每个失败点给出一个会触发它的具体输入,并写成 pytest 用例。

这种角色切换能逼出它平时忽略的异常路径。但要清醒:AI 比人更容易系统性遗漏那些”看不见的假设”(比如时区、字符编码、浮点精度),所以它列不全边界时,需要你用经验补刀——这正是人机协作的分工。

把 AI 测试纳入 CI

把 AI 生成测试从”本地辅助”推进到”流程基建”,关键在两点:在 PR 阶段自动给出测试建议,与覆盖率门禁结合。

PR 阶段的测试建议

可在 PR 创建或更新时,用 AI 对本次 diff 生成”建议测试清单”,作为评审附件而非自动合并:

# 示意:用非交互模式让 AI 针对 diff 产出测试建议
claude -p "针对当前 git diff,列出值得补充单元测试的分支与边界,
输出 Markdown 测试清单,不要改代码。" --output-format text

轻量环节(lint、已有单测运行)全自动;AI 产出的”测试建议”作为报告供人决策,不直接替你写进仓库,避免噪音合并。

与覆盖率门禁结合

Coverage.py(Python)、Vitest/Jest 的 coverage(JS)都能在 CI 里生成覆盖率报告并设置门禁:

# Python:跑测试并生成覆盖率,低于 80% 视为失败
pytest --cov=src --cov-fail-under=80
# JavaScript(Vitest):生成覆盖率,结合 CI 阈值配置
vitest run --coverage

做法是:让 AI 先补测试把覆盖率抬到门禁线附近,再把”覆盖率不达标就阻断合并”写进 CI。这样 AI 负责”省力地补”,门禁负责”守住底线”。本质是用确定性规则约束不确定的 AI 产出

集成原则:AI 当”第一道滤网”和”省力生成器”,把人从重复造用例中解放;但断言质量的最终判断、以及合并决策,留在人手里。

局限与风险

把 AI 测试用得顺手,前提是正视它的系统性疾病:

  • 假性通过(弱断言):AI 最爱写 assert response is not None 或只验证”没抛异常”。这种测试覆盖率很高、绿得很稳,却在代码真出错时毫无反应。必须用 mutation 思维反向检验:故意改坏一行,看测试变不变红。
  • 测试实现而非行为:AI 容易根据当前实现细节写测试(比如断言了内部中间变量、私有方法调用顺序),重构一下就全碎,且没测到用户关心的行为。优先测”输入输出契约”。
  • Mock 路径错误导致假绿:前述 patch 打错对象,测试看似通过,实则没隔离到真实依赖,CI 里偶发失败最难排查。
  • 边界覆盖不全:AI 默认偏 happy path,需要你强制点名边界,否则异常分支仍是盲区。
  • 对”领域假设”无知:字符编码、时区、货币精度、并发语义这类隐含约定,AI 没有业务上下文,容易写错或漏测。
  • 安全责任在人:AI 生成的测试不能替代人工对关键路径的审查。测试是证据,不是保险。

工具能力声明以官方文档为准:GitHub Copilot 的测试生成、Cursor 的 AI 补全、pytest/Coverage.py/Vitest/Jest 的覆盖率能力,请以下方参考链接中的官方说明为准,本文不对其版本特性做具体断言。

小结

  1. 测试模板化、可验证、反馈即上下文,是 AI 编程里性价比最高的场景之一,但”覆盖率提升幅度”取决于场景,不宜给出统一数字。
  2. 覆盖率驱动生成:先给 AI 提供被测函数、依赖、项目约定三类上下文,再要求它按分支/边界列计划、后写代码。
  3. Mock/Stub 自动构造能隔离数据库、网络、时间等依赖;陷阱集中在 patch 路径错误、mock 过厚、断言太弱。
  4. AI 默认偏 happy path,必须用检查单式提示显式点名空/超长/非法/并发/异常分支,甚至让它扮演”对抗者”找失败点。
  5. 在 CI 中让 AI 产出 PR 测试建议(出报告而非自动合并),并用 Coverage.py/Vitest 的覆盖率门禁作为确定性底线来约束 AI 产出。
  6. 核心风险是假性通过与测试实现而非行为;用 mutation 思维反向检验、人工审查断言质量,安全责任始终在人。

参考与延伸阅读

  1. GitHub Copilot 官方文档(测试生成、补全能力说明):https://docs.github.com/en/copilot
  2. Cursor 官方文档(AI 代码补全与生成):https://docs.cursor.com/
  3. pytest 官方文档:https://docs.pytest.org/
  4. Coverage.py 官方文档(Python 覆盖率度量与门禁):https://coverage.readthedocs.io/
  5. Vitest 官方文档(含 vi.mock 与 coverage 配置):https://vitest.dev/
  6. Jest 官方文档(含 jest.mock 与覆盖率):https://jestjs.io/
  7. Python unittest.mock 标准库文档:https://docs.python.org/3/library/unittest.mock.html
  8. Anthropic,Claude Code 最佳实践(非交互模式、hooks 与 CI 集成):https://code.claude.com/docs/en/best-practices
本文累计阅读