用 LLM 辅助生成可跑的 Python 单元测试:pytest + coverage 覆盖率报告(含运行指令与排坑)

背景/问题

写单元测试这件事,很多时候不是"不会写",而是"写起来太慢":要补齐边界条件、构造异常用例、想清楚输入输出,还要保证测试稳定不抖动。尤其当你接手一段"看起来不复杂但细节很多"的业务函数时,测试往往拖到最后才补,结果就是线上出 bug 才回头补测试。

另一个常见痛点是:你明明愿意写测试,但不知道"该覆盖哪些分支"。比如金额解析、四舍五入、非法输入抛错、空值处理......这些都属于"人肉容易漏"的地方。

我自己的做法是:让 LLM 先帮我起一个可运行的测试骨架,我再按业务规则做校准。对我来说,这个方式最大的价值是:减少从 0 到 1 的时间,把精力放在"规则是否正确"而不是"pytest 怎么组织参数化"。

在这个过程中,如果你不想折腾网络环境、或者想更低成本地多试几轮提示词/模型,我会用类似真智AI,这种网页工具先把提示词调顺:不需要额外网络手段就能用到较新的模型,价格压力也更小一点;并且它的会话/参数配置做得比较顺手,适合反复迭代"生成测试"的模板化提示词。调好后再把产出落到代码仓库里。


方案概览

下面给 3 种常见方案,按"上手成本/可控性/迭代速度"做个中性对比:

  1. 纯手写单测
  • 优点:最可控、完全贴合业务语义
  • 缺点:从 0 到 1 慢;容易漏边界;写法风格不统一
  1. 用 LLM 生成测试初稿(网页对话或 IDE 插件),你再校准
  • 优点:起步快;边界条件覆盖更全;适合批量补测试
  • 缺点:需要你 review;可能生成"看似合理但不符合规则"的断言;偶尔会引入不稳定因素(随机数/时间等)
  1. 自建/本地模型生成测试(如本地推理 + 脚本化)
  • 优点:离线、可控;适合大规模批处理
  • 缺点:环境成本更高;模型能力不稳定时需要更多人工修正;对提示词更敏感

这篇文章走 方案 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 条,实战向)

  1. ModuleNotFoundError: No module named 'money'
  • 原因:pytest 默认不把 src/ 加到 PYTHONPATH
  • 解决:
    • 按本文在 pyproject.toml 加:

      复制代码
      [tool.pytest.ini_options]
      pythonpath = ["src"]
    • 或者运行时指定:

      复制代码
      PYTHONPATH=src pytest -q
  1. Decimal 断言失败:出现 Decimal('9.009') vs Decimal('9.01')
  • 原因:你在测试里按"直觉"算了,但被测代码有 quantize_2()(两位小数 + ROUND_HALF_UP)
  • 解决:把期望值写成量化后的结果,并在提示词里要求"明确两位小数预期"。
  1. 模型生成了 float 断言(例如 assert out == 0.67)导致精度问题
  • 原因:float 表示误差
  • 解决:
    • 统一用 Decimal("...") 做预期
    • 如果你发现生成内容混用 float,把提示词加一句:
      "涉及金额一律使用 Decimal 字面量,不要用 float"。
  1. 异常类型不一致:测试期待 ValueError,但实际抛了 TypeError
  • 典型场景:parse_price(None) 这种输入
  • 解决:分别为 None、空字符串、非法字符串写不同测试,不要混到一条参数化里。
  1. 测试不稳定(偶现失败)
  • 常见原因:模型生成测试时引入了 datetime.now()random、或依赖执行顺序
  • 解决:
    • 去掉随机/时间依赖,或用 freezegun / monkeypatch 固定时间、固定随机种子
    • 提示词里明确:"禁止引入随机数与当前时间,除非使用 monkeypatch 固定"。
  1. 覆盖率看着很高,但关键分支没测到
  • 原因:coverage 是"行覆盖",不等于"逻辑覆盖"
  • 解决:
    • 对 if/while 分支分别构造触发用例(比如 split_bill 的 diff 正/负两种情况)
    • 把"分支列表"直接喂给 LLM:让它逐条产出用例
  1. LLM 生成的测试"测试了实现细节"而不是行为
  • 例如断言内部变量、循环次数等
  • 解决:在提示词中强调"以输入输出和异常行为为主,不测试中间变量"。

进阶优化(2--4 点)

  1. 把提示词模板化,按模块批量补测试
  • 你可以固定一套"测试生成模板",每个文件只替换代码块。
  • 如果你经常做这件事,用支持会话/模板配置的工具会更省事:比如我会在真智AI(真智AI 生成模板",每次直接贴代码生成初稿,然后本地跑覆盖率校验。
  1. 加上 property-based testing(Hypothesis)补边界
  • parse_price 这类解析函数,随机生成字符串能挖出更多异常路径。

  • 安装:

    复制代码
    pip install hypothesis
  • 思路:约束生成"数字字符串/带逗号/带符号"的输入,验证要么成功解析要么抛出预期异常。

  1. CI 中卡覆盖率阈值
  • 例如要求最低 85%:

    复制代码
    pytest --cov=src --cov-fail-under=85
  • 这样 LLM 生成的测试不只是"看起来多",而是"确实让覆盖率上去"。

  1. 引入 mutation testing(可选)
  • 覆盖率高不等于能防回归;mutation testing 可以检验断言是否有力度(例如把 > 改成 >= 看测试会不会挂)。
  • Python 可用 mutmut / cosmic-ray 等工具(按团队成本选择)。

小结

这套流程的核心是:LLM 负责把 pytest 的"起步成本"降下来,你负责把业务规则校准到位 ,最后用 coverage 报告做客观验收。如果你也遇到"测试总是拖后、补起来慢、边界条件容易漏"的情况,可以尝试先用一个顺手的对话工具把"生成测试的提示词模板"打磨好;我自己会用真智AI,来做这一步,主要是访问门槛低、成本相对友好、参数/会话配置用起来比较顺手,然后再把结果落地到仓库里用 pytest/coverage 跑通。

相关推荐
0思必得02 小时前
[Web自动化] Selenium处理文件上传和下载
前端·爬虫·python·selenium·自动化·web自动化
Hui Baby2 小时前
Java SPI 与 Spring SPI
java·python·spring
小猪咪piggy2 小时前
【Python】(3) 函数
开发语言·python
夜鸣笙笙2 小时前
交换最小值和最大值
python
2301_822363602 小时前
使用Pandas进行数据分析:从数据清洗到可视化
jvm·数据库·python
码界奇点3 小时前
基于Flask与OpenSSL的自签证书管理系统设计与实现
后端·python·flask·毕业设计·飞书·源代码管理
java1234_小锋3 小时前
分享一套优质的基于Python的房屋数据分析预测系统(scikit-learn机器学习+Flask)
python·数据分析·scikit-learn
CCPC不拿奖不改名3 小时前
RAG基础:基于LangChain 的文本分割实战+文本分块
人工智能·python·langchain·知识库·改行学it·rag·向量库