AI 辅助单元测试深入:覆盖率、Mock 与性质测试

很多团队用 AI 生成测试,停在「写一个 hello world 级别的测试让它变绿」这一步,结果覆盖率没涨、外部依赖没隔离、边界没触达。本文讲进阶做法:让 AI 基于覆盖率报告补分支、用 Mock 切掉数据库与网络、主动枚举边界与异常、用 Hypothesis 写不变量,再把测试安全地接进 CI,并学会识别 AI 写出的假通过测试。

一、AI 生成测试的进阶用法

AI 不只是「给函数补一个 happy path」。真正有价值的提示词,是把上下文喂足:被测试代码、现有测试、项目约定、待测目标。一个可复用的模板如下。

请为以下函数编写 pytest 单元测试:
1. 覆盖正常路径与一个代表性异常路径;
2. 遵循本仓库既有测试风格(fixture 优先、纯 assert);
3. 不改动业务代码;
4. 每个测试只断言一个关注点,函数名以 test_ 开头并具有可读性。

[粘贴被测函数与 import]

进阶要点:

  • 让 AI 先读再写。把被测模块、相关类型定义、已有测试一并发给它,能显著降低编造 API 的风险。
  • 让 AI 列出测试清单再实现。先要它产出「我打算覆盖哪些分支与边界」,你确认后再生成代码,减少返工。
  • 把测试当产物而非答案。AI 生成的测试默认是草稿,必须用执行结果和人工审查来兜底。

提示:pytest 对失败断言的内省很友好,测试里直接写 assert 即可,无需记 self.assertEqual 这类名称(官方文档已核验:pytest 支持原生 assert 与自动发现 test 模块)。

二、覆盖率驱动生成:让 AI 针对未覆盖分支补测试

只凭感觉让 AI 补测试,容易重复覆盖已经测过的地方。更好的闭环是:先跑覆盖率,把「未覆盖的行」直接交给 AI。

安装与运行(命令细节待核实,建议以官方文档为准):

pip install pytest pytest-cov
pytest --cov=myapp --cov-report=term-missing

term-missing 会列出每个文件哪些行没跑到。你把这些行号与对应源码贴给 AI:

以下文件 myapp/pricing.py 的覆盖率报告未覆盖行:
- 行 34:promo 时段结束分支
- 行 41:price 接口返回非数字时抛 ValueError

请只针对这两个未覆盖分支补 pytest 测试,复用现有 fixture。

也可以设定失败阈值,让 CI 在覆盖率下滑时直接报错:

pytest --cov=myapp --cov-fail-under=80

要点:覆盖率数字本身不是目标。让 AI 补的是「分支语义」,而不是为了把百分比刷上去而写无意义的断言。

三、Mock 与 Stub:用 AI 构造外部依赖桩

测试一旦碰到数据库、网络、时间,就会变慢且不稳定。用 Mock 把外部依赖替换成可控桩,是让测试可信赖的关键。AI 很擅长照着接口造桩,但你需要确认它桩的是正确的对象(patch 目标要指向「被测试模块里引用的名字」)。

下面是被测模块示例(含时间与网络两类外部依赖):

# pricing.py
from datetime import datetime, timezone
import requests


def is_promo_active(now=None):
    # 默认取当前时间;测试时可注入
    now = now or datetime.now(timezone.utc)
    return now.hour < 9 or now.hour >= 22


def fetch_price(symbol):
    resp = requests.get(f"https://api.example.com/price/{symbol}", timeout=5)
    resp.raise_for_status()
    return float(resp.json()["price"])

时间依赖用「注入 now 参数」即可测试,无需 mock 时间库:

# test_pricing.py
from datetime import datetime, timezone
from unittest.mock import patch
import pricing


def test_is_promo_active_off_hours():
    day = datetime(2026, 1, 1, 10, 0, tzinfo=timezone.utc)
    assert pricing.is_promo_active(day) is False


def test_is_promo_active_promo_hours():
    night = datetime(2026, 1, 1, 23, 0, tzinfo=timezone.utc)
    assert pricing.is_promo_active(night) is True

网络依赖用 unittest.mock.patch 替换 requests.get,并构造返回桩:

def test_fetch_price_uses_mock():
    with patch("pricing.requests.get") as mock_get:
        fake_resp = mock_get.return_value
        fake_resp.raise_for_status.return_value = None
        fake_resp.json.return_value = {"price": "12.34"}
        assert pricing.fetch_price("AAPL") == 12.34

如果项目用 pytest-mock(mocker fixture,具体用法待核实),等价写法更简洁:

def test_fetch_price_with_mocker(mocker):
    fake = mocker.Mock()
    fake.json.return_value = {"price": "12.34"}
    fake.raise_for_status.return_value = None
    mocker.patch("pricing.requests.get", return_value=fake)
    assert pricing.fetch_price("AAPL") == 12.34

给 AI 的提示建议明确「桩的边界」:

为 fetch_price 编写测试,用 unittest.mock.patch 替换 pricing.requests.get,
构造成功返回与超时异常两种情况;不要真实发起网络请求。

四、边界与异常路径:让 AI 主动枚举

AI 容易写出一堆 happy path。要在提示里强制它「先列边界再写测试」。以除法为例:

# calc.py
def divide(a, b):
    if b == 0:
        raise ValueError("b 不能为 0")
    return a / b

让 AI 枚举的边界应包含:除数为零、负数、极大值、浮点精度、a 为零。示例测试:

import pytest
from calc import divide


def test_divide_normal():
    assert divide(10, 2) == 5


def test_divide_by_zero_raises():
    with pytest.raises(ValueError):
        divide(1, 0)


def test_divide_zero_numerator():
    assert divide(0, 5) == 0


def test_divide_float_precision():
    assert divide(1, 3) != 0.3333333333333333  # 不要求精确,仅验证返回浮点

并发竞争也要让 AI 显式构造。下面是一个共享计数器的竞态示例,AI 可生成多线程压测来暴露问题:

import threading


class Counter:
    def __init__(self):
        self.value = 0

    def inc(self):
        # 非原子操作,存在竞态
        self.value += 1


def test_counter_race():
    c = Counter()
    threads = [threading.Thread(target=c.inc) for _ in range(100)]

    def run_all():
        for t in threads:
            t.start()
        for t in threads:
            t.join()

    run_all()
    assert c.value != 100  # 竞态存在时可能小于 100,用于演示不稳定

注意:上面这个测试本身是不稳定的(它可能偶然通过)。这类用例更适合作为「演示竞态」的教学样本,而非长期留在套件里。真实生产环境应改用锁或原子结构,并写稳定的确定性测试。

五、性质测试:用 Hypothesis 让 AI 写不变量

普通单元测试是「给固定输入、看固定输出」。性质测试(property-based testing)反过来:你声明输入应满足的「不变量」,工具自动生成大量随机输入去证伪它。Python 里用 Hypothesis(API 已核验)。

以自定义的排序函数为例,不变量是「元素集合不变」且「结果单调不减」:

# sortutil.py
def my_sort(lst):
    return sorted(lst)
from hypothesis import given, strategies as st
from sortutil import my_sort


@given(st.lists(st.integers()))
def test_sort_keeps_elements(lst):
    result = my_sort(lst)
    assert set(result) == set(lst)
    assert all(a <= b for a, b in zip(result, result[1:]))


@given(st.lists(st.text()))
def test_sort_text_stable_enough(lst):
    result = my_sort(lst)
    assert len(result) == len(lst)
    assert all(a <= b for a, b in zip(result, result[1:]))

要点(已核验):

  • @given(strategy) 装饰测试函数,Hypothesis 默认生成 100 组随机输入,可用 settings(max_examples=200) 调整。
  • st.integers()st.text()st.lists() 等策略描述输入空间;用 .filter()assume() 排除非法组合。
  • 一旦找到反例,Hypothesis 会报告最小化的失败样例(例如 n=50),并自动回放,便于定位。

可让 AI 先提出「这个函数有哪些不变量」,再据此写 @given 测试。对解析器、序列化、压缩这类「输入输出关系稳定」的函数尤其有效。

补充:@example 装饰器可固定一个具体样例,确保关键输入每次必跑(该装饰器为 Hypothesis 标准 API,本文未单独核验,建议以官方文档为准)。

六、AI 测试纳入 CI:PR 触发、失败阈值与 flaky 处理

把 AI 生成的测试接进持续集成,要保证「门禁有效、且不因偶发失败而瘫痪」。一个 GitHub Actions 片段如下(具体步骤待核实,建议核对平台最新语法):

name: tests
on:
  pull_request:
    branches: [main]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - run: pip install pytest pytest-cov
      - run: pytest --cov=myapp --cov-fail-under=80

实践建议:

  • PR 触发:每次 PR 都跑全量测试与覆盖率,避免低覆盖代码混入主干。
  • 失败阈值:--cov-fail-under 设红线,但阈值要从现状平滑提升,别一上来定 100%。
  • flaky 测试处理:先隔离再修。可临时用 pytest-rerunfailures(具体参数待核实)重跑定位偶发,但更稳妥的是把 flaky 用例放进隔离目录或标记 skip 待修,不让它污染主信号。
  • 审查门槛:AI 生成的测试合入前,必须经过一次人工或资深成员审查,重点看断言是否真实。

七、信任边界:识别 AI 写的假通过测试

AI 有时会写出「看起来在测试、其实恒真」的测试,让覆盖率上涨但毫无保护力。典型症状:

# 假通过:无论实现如何都会绿
def test_fetch_price_is_not_none():
    result = pricing.fetch_price("AAPL")
    assert result is not None  # 即使返回错误占位值也通过

更隐蔽的是恒真断言:

def test_always_true():
    x = compute()
    assert x == x  # 恒真,毫无意义

审查清单:

  • 断言是否绑定了真实输出?优先断言「具体期望值」或「可证伪的不变量」,避免 assert Trueassert x is not None 这类弱断言。
  • 桩是否真的被调用?检查 patch 目标是否正确、mock 的返回值是否参与断言(例如 mock_get.assert_called_once())。
  • 测试是否依赖了真实外部资源?确认没有意外发起网络/访问数据库。
  • 覆盖率上升是否来自有效分支?对照 term-missing,看新增行是否真被有意义地执行。
  • 让 AI 自我审查:把测试贴回给它,问「这个测试在哪些实现错误下仍然会通过」,逼出恒真漏洞。

一句话:AI 能 expansion 测试数量,不能替代你对「测试是否在保护代码」的判断。

小结

  • 让 AI 基于覆盖率报告与未覆盖行号补测试,比盲目生成更有效。
  • 用 Mock/Stub 隔离时间、网络、数据库,测试才能快且稳定;patch 目标要指向被测模块内的引用名。
  • 强制 AI 先枚举边界与异常(含并发竞态),再写测试,避免一堆 happy path。
  • 用 Hypothesis 写不变量,让随机输入替你发现未想到的反例。
  • 接入 CI 时设失败阈值、隔离 flaky,并把 AI 测试纳入人工审查门槛。
  • 始终警惕假通过测试:核对断言是否可证伪、桩是否被调用、是否偷偷依赖外部资源。

参考与延伸阅读

  • pytest 官方文档(断言内省、fixture、自动发现、兼容 unittest):https://docs.pytest.org/ —— 已核验
  • pytest-cov 官方文档(—cov、—cov-report、—cov-fail-under 等):https://pytest-cov.readthedocs.io/ —— 已核验
  • Hypothesis 官方文档(@given、strategies、assume、settings、失败样例最小化):https://hypothesis.readthedocs.io/ —— 已核验
  • unittest.mock 标准库文档(patch、Mock、MagicMock):https://docs.python.org/3/library/unittest.mock.html —— 待核实(本文示例基于此编写,未单独核验)
  • pytest-mock、pytest-rerunfailures 插件用法 —— 待核实
  • GitHub Actions 工作流语法 —— 待核实(文中 YAML 片段为示意,请以平台最新文档为准)
  • Hypothesis 的 @example 装饰器语义 —— 待核实(标准 API,本文未单独核验)
本文累计阅读