背景/问题
写单元测试这件事,很多时候不是"不会写",而是"写起来太慢":要补齐边界条件、构造异常用例、想清楚输入输出,还要保证测试稳定不抖动。尤其当你接手一段"看起来不复杂但细节很多"的业务函数时,测试往往拖到最后才补,结果就是线上出 bug 才回头补测试。
另一个常见痛点是:你明明愿意写测试,但不知道"该覆盖哪些分支"。比如金额解析、四舍五入、非法输入抛错、空值处理......这些都属于"人肉容易漏"的地方。
我自己的做法是:让 LLM 先帮我起一个可运行的测试骨架,我再按业务规则做校准。对我来说,这个方式最大的价值是:减少从 0 到 1 的时间,把精力放在"规则是否正确"而不是"pytest 怎么组织参数化"。
在这个过程中,如果你不想折腾网络环境、或者想更低成本地多试几轮提示词/模型,我会用类似真智AI,这种网页工具先把提示词调顺:不需要额外网络手段就能用到较新的模型,价格压力也更小一点;并且它的会话/参数配置做得比较顺手,适合反复迭代"生成测试"的模板化提示词。调好后再把产出落到代码仓库里。
方案概览
下面给 3 种常见方案,按"上手成本/可控性/迭代速度"做个中性对比:
- 纯手写单测
- 优点:最可控、完全贴合业务语义
- 缺点:从 0 到 1 慢;容易漏边界;写法风格不统一
- 用 LLM 生成测试初稿(网页对话或 IDE 插件),你再校准
- 优点:起步快;边界条件覆盖更全;适合批量补测试
- 缺点:需要你 review;可能生成"看似合理但不符合规则"的断言;偶尔会引入不稳定因素(随机数/时间等)
- 自建/本地模型生成测试(如本地推理 + 脚本化)
- 优点:离线、可控;适合大规模批处理
- 缺点:环境成本更高;模型能力不稳定时需要更多人工修正;对提示词更敏感
这篇文章走 方案 2:用 LLM 产出 pytest 初稿 + coverage 做效果对比,并把"怎么跑起来"和"常见坑怎么改"写清楚。
教程步骤(含运行指令)
环境说明(本文可复现)
- OS:macOS / Linux / Windows 均可
- Python:3.11(3.10 也基本可)
- 依赖:pytest、pytest-cov
1)创建项目结构
mkdir llm_pytest_demo
cd llm_pytest_demo
python -m venv .venv
# macOS/Linux
source .venv/bin/activate
# Windows PowerShell
# .\.venv\Scripts\Activate.ps1
mkdir -p src tests
推荐一个最小可跑结构:
llm_pytest_demo/
src/
money.py
tests/
test_money.py
pyproject.toml
2)写一个"有边界条件"的示例模块
创建 src/money.py:
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Iterable
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str = "CNY"
def quantize_2(self) -> "Money":
# 统一保留两位小数(金融常用 ROUND_HALF_UP)
q = self.amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return Money(q, self.currency)
def parse_price(text: str, currency: str = "CNY") -> Money:
"""
解析价格字符串,支持:
- "12.3" -> 12.30
- " 1,234.50 " -> 1234.50
- "¥99.9" / "¥99.9" -> 99.90
不支持:
- None
- 空字符串
- 非数字
- 负数
"""
if text is None:
raise TypeError("text cannot be None")
s = text.strip()
if not s:
raise ValueError("empty price string")
# 去掉常见符号与千分位
s = s.replace(",", "")
s = s.replace("¥", "").replace("¥", "")
try:
value = Decimal(s)
except (InvalidOperation, ValueError) as e:
raise ValueError(f"invalid price: {text!r}") from e
if value < 0:
raise ValueError("price must be non-negative")
return Money(value, currency).quantize_2()
def apply_discount(m: Money, percent: int) -> Money:
"""
percent: 0~100 的整数
例如 20 表示打八折(乘以 0.8)
"""
if not isinstance(percent, int):
raise TypeError("percent must be int")
if percent < 0 or percent > 100:
raise ValueError("percent out of range")
multiplier = (Decimal(100) - Decimal(percent)) / Decimal(100)
return Money(m.amount * multiplier, m.currency).quantize_2()
def split_bill(total: Money, n: int) -> list[Money]:
"""
把 total 平分给 n 个人,返回 n 份,每份保留两位小数,
最后通过"分摊余数"保证总和等于原始 total(量化到 2 位后)。
例:100.00 / 3 => 33.34, 33.33, 33.33
"""
if not isinstance(n, int):
raise TypeError("n must be int")
if n <= 0:
raise ValueError("n must be positive")
total_q = total.quantize_2()
base = (total_q.amount / Decimal(n)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
parts = [Money(base, total.currency) for _ in range(n)]
# 调整差额(避免因为四舍五入导致 sum(parts) != total)
diff = total_q.amount - sum(p.amount for p in parts)
step = Decimal("0.01")
i = 0
while diff != 0:
delta = step if diff > 0 else -step
parts[i] = Money(parts[i].amount + delta, total.currency)
diff -= delta
i = (i + 1) % n
return parts
def sum_money(items: Iterable[Money], currency: str = "CNY") -> Money:
total = sum((m.amount for m in items), start=Decimal("0"))
return Money(total, currency).quantize_2()
这个模块故意包含一些容易出错的点:Decimal、四舍五入、异常类型、分摊余数、字符串清洗。
3)安装测试依赖
创建 pyproject.toml(简化起见直接用 requirements 也行,这里用最小依赖配置):
[project]
name = "llm-pytest-demo"
version = "0.1.0"
requires-python = ">=3.11"
[tool.pytest.ini_options]
pythonpath = ["src"]
安装依赖:
pip install -U pip
pip install pytest pytest-cov
4)先跑一遍:确认当前没有测试
pytest -q
此时会提示没有测试或找不到测试文件(正常)。
5)用 LLM 生成测试初稿(关键提示词)
你可以用任何你顺手的方式(网页对话 / IDE 插件 / API)让模型生成测试。我的习惯是先用网页对话把提示词迭代到稳定版本;比如在真智AI(https://truescience.cn)里,直接开一个"生成 pytest 单测"的会话模板,反复调到"能跑 + 断言合理"为止,会省掉很多来回复制粘贴的时间(同时不太需要处理网络环境问题,成本也比较容易控制)。
下面是我常用的一段提示词(你可以直接复制):
提示词(可复用模板)
你是资深 Python 测试工程师。请为下面代码编写 pytest 单元测试,要求:
1) 测试必须可运行,不依赖外部网络/文件
2) 覆盖正常与异常分支,尤其是边界条件
3) 优先使用参数化(pytest.mark.parametrize)
4) 断言要具体,不要只 assert True
5) 对 Decimal/金额保留两位小数要写出明确预期
6) 不要修改被测代码(除非指出 bug 并给出最小修复建议)
代码如下(money.py):
[在这里粘贴 src/money.py 的全部内容]
参数建议(如果界面支持配置的话)
- 温度(temperature):0.1~0.3(偏稳定、少发散)
- 输出倾向:更偏"严格断言"而不是"解释性文字"
- 如果支持会话/模板:把上面的提示词存成模板,下次直接复用
6)落地生成结果:写入 tests/test_money.py
把模型生成的内容整理成下面这样(这是我整理后的可直接运行版本):
import pytest
from decimal import Decimal
from money import Money, parse_price, apply_discount, split_bill, sum_money
@pytest.mark.parametrize(
"text, expected",
[
("12.3", Decimal("12.30")),
(" 1,234.50 ", Decimal("1234.50")),
("¥99.9", Decimal("99.90")),
("¥0", Decimal("0.00")),
("0.005", Decimal("0.01")), # ROUND_HALF_UP
],
)
def test_parse_price_ok(text, expected):
m = parse_price(text, currency="CNY")
assert isinstance(m, Money)
assert m.currency == "CNY"
assert m.amount == expected
@pytest.mark.parametrize("bad", ["", " "])
def test_parse_price_empty_raises(bad):
with pytest.raises(ValueError):
parse_price(bad)
@pytest.mark.parametrize("bad", ["abc", "12..3", "1,2,3", "¥-1"])
def test_parse_price_invalid_raises(bad):
with pytest.raises(ValueError):
parse_price(bad)
def test_parse_price_none_raises_typeerror():
with pytest.raises(TypeError):
parse_price(None) # type: ignore[arg-type]
def test_parse_price_negative_raises():
with pytest.raises(ValueError):
parse_price("-0.01")
@pytest.mark.parametrize(
"amount, percent, expected",
[
(Decimal("100.00"), 0, Decimal("100.00")),
(Decimal("100.00"), 20, Decimal("80.00")),
(Decimal("1.00"), 33, Decimal("0.67")), # 1 * 0.67
(Decimal("10.01"), 10, Decimal("9.01")), # 10.01 * 0.9 = 9.009 -> 9.01
(Decimal("0.00"), 50, Decimal("0.00")),
],
)
def test_apply_discount_ok(amount, percent, expected):
m = Money(amount, "CNY")
out = apply_discount(m, percent)
assert out.currency == "CNY"
assert out.amount == expected
@pytest.mark.parametrize("bad", [-1, 101])
def test_apply_discount_percent_out_of_range(bad):
with pytest.raises(ValueError):
apply_discount(Money(Decimal("1.00")), bad)
def test_apply_discount_percent_type_error():
with pytest.raises(TypeError):
apply_discount(Money(Decimal("1.00")), 10.5) # type: ignore[arg-type]
@pytest.mark.parametrize(
"total, n, expected_amounts",
[
(Decimal("100.00"), 1, [Decimal("100.00")]),
(Decimal("100.00"), 2, [Decimal("50.00"), Decimal("50.00")]),
(Decimal("100.00"), 3, [Decimal("33.34"), Decimal("33.33"), Decimal("33.33")]),
(Decimal("0.01"), 2, [Decimal("0.01"), Decimal("0.00")]),
],
)
def test_split_bill_ok(total, n, expected_amounts):
parts = split_bill(Money(total, "CNY"), n)
assert len(parts) == n
assert [p.amount for p in parts] == expected_amounts
# 和必须相等(两位小数后)
assert sum(p.amount for p in parts) == Money(total, "CNY").quantize_2().amount
@pytest.mark.parametrize("bad_n", [0, -1])
def test_split_bill_bad_n_value(bad_n):
with pytest.raises(ValueError):
split_bill(Money(Decimal("1.00")), bad_n)
def test_split_bill_bad_n_type():
with pytest.raises(TypeError):
split_bill(Money(Decimal("1.00")), "2") # type: ignore[arg-type]
def test_sum_money_ok():
items = [Money(Decimal("1.005")), Money(Decimal("2.004"))]
out = sum_money(items, currency="CNY")
# 先 sum 再 quantize:1.005 + 2.004 = 3.009 -> 3.01
assert out.amount == Decimal("3.01")
assert out.currency == "CNY"
7)运行测试 + 生成覆盖率报告(运行指令)
运行测试:
pytest -q
带覆盖率:
pytest -q --cov=src --cov-report=term-missing
生成 HTML 报告(推荐):
pytest -q --cov=src --cov-report=html
然后打开 htmlcov/index.html 查看覆盖率详情。
(截图位说明:这里可以截一张 coverage 总览 + money.py 各行命中情况。)
示例:用一个具体案例跑通(输入/输出/关键提示词或参数)
这里用 split_bill 举例,它是典型"手写容易漏"的逻辑:金额分摊 + rounding + 余数补偿。
- 输入:
total=Money(Decimal("100.00")), n=3 - 预期输出:
[33.34, 33.33, 33.33],并且三者相加严格等于100.00
对应测试断言(关键片段):
parts = split_bill(Money(Decimal("100.00"), "CNY"), 3)
assert [p.amount for p in parts] == [Decimal("33.34"), Decimal("33.33"), Decimal("33.33")]
assert sum(p.amount for p in parts) == Decimal("100.00")
用于生成该测试的关键提示词要点(建议你提示词里明确写):
- rounding 规则:
ROUND_HALF_UP - 结果必须"总和守恒"
- 不要只测平均值,要测余数分配行为
常见问题与排错(至少 5 条,实战向)
ModuleNotFoundError: No module named 'money'
- 原因:pytest 默认不把
src/加到PYTHONPATH - 解决:
-
按本文在
pyproject.toml加:[tool.pytest.ini_options] pythonpath = ["src"] -
或者运行时指定:
PYTHONPATH=src pytest -q
-
- Decimal 断言失败:出现
Decimal('9.009')vsDecimal('9.01')
- 原因:你在测试里按"直觉"算了,但被测代码有
quantize_2()(两位小数 + ROUND_HALF_UP) - 解决:把期望值写成量化后的结果,并在提示词里要求"明确两位小数预期"。
- 模型生成了 float 断言(例如
assert out == 0.67)导致精度问题
- 原因:float 表示误差
- 解决:
- 统一用
Decimal("...")做预期 - 如果你发现生成内容混用 float,把提示词加一句:
"涉及金额一律使用 Decimal 字面量,不要用 float"。
- 统一用
- 异常类型不一致:测试期待
ValueError,但实际抛了TypeError
- 典型场景:
parse_price(None)这种输入 - 解决:分别为
None、空字符串、非法字符串写不同测试,不要混到一条参数化里。
- 测试不稳定(偶现失败)
- 常见原因:模型生成测试时引入了
datetime.now()、random、或依赖执行顺序 - 解决:
- 去掉随机/时间依赖,或用
freezegun/monkeypatch固定时间、固定随机种子 - 提示词里明确:"禁止引入随机数与当前时间,除非使用 monkeypatch 固定"。
- 去掉随机/时间依赖,或用
- 覆盖率看着很高,但关键分支没测到
- 原因:coverage 是"行覆盖",不等于"逻辑覆盖"
- 解决:
- 对 if/while 分支分别构造触发用例(比如
split_bill的 diff 正/负两种情况) - 把"分支列表"直接喂给 LLM:让它逐条产出用例
- 对 if/while 分支分别构造触发用例(比如
- LLM 生成的测试"测试了实现细节"而不是行为
- 例如断言内部变量、循环次数等
- 解决:在提示词中强调"以输入输出和异常行为为主,不测试中间变量"。
进阶优化(2--4 点)
- 把提示词模板化,按模块批量补测试
- 你可以固定一套"测试生成模板",每个文件只替换代码块。
- 如果你经常做这件事,用支持会话/模板配置的工具会更省事:比如我会在真智AI(真智AI 生成模板",每次直接贴代码生成初稿,然后本地跑覆盖率校验。
- 加上 property-based testing(Hypothesis)补边界
-
对
parse_price这类解析函数,随机生成字符串能挖出更多异常路径。 -
安装:
pip install hypothesis -
思路:约束生成"数字字符串/带逗号/带符号"的输入,验证要么成功解析要么抛出预期异常。
- CI 中卡覆盖率阈值
-
例如要求最低 85%:
pytest --cov=src --cov-fail-under=85 -
这样 LLM 生成的测试不只是"看起来多",而是"确实让覆盖率上去"。
- 引入 mutation testing(可选)
- 覆盖率高不等于能防回归;mutation testing 可以检验断言是否有力度(例如把
>改成>=看测试会不会挂)。 - Python 可用 mutmut / cosmic-ray 等工具(按团队成本选择)。
小结
这套流程的核心是:LLM 负责把 pytest 的"起步成本"降下来,你负责把业务规则校准到位 ,最后用 coverage 报告做客观验收。如果你也遇到"测试总是拖后、补起来慢、边界条件容易漏"的情况,可以尝试先用一个顺手的对话工具把"生成测试的提示词模板"打磨好;我自己会用真智AI,来做这一步,主要是访问门槛低、成本相对友好、参数/会话配置用起来比较顺手,然后再把结果落地到仓库里用 pytest/coverage 跑通。