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 True、assert 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,本文未单独核验)