欢迎光临
我们一直在努力

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

背景/问题

写单元测试这件事,很多时候不是“不会写”,而是“写起来太慢”:要补齐边界条件、构造异常用例、想清楚输入输出,还要保证测试稳定不抖动。尤其当你接手一段“看起来不复杂但细节很多”的业务函数时,测试往往拖到最后才补,结果就是线上出 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') vs Decimal('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:让它逐条产出用例
  • 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 跑通。

    赞(0)
    未经允许不得转载:171主机测评 » 用 LLM 辅助生成可跑的 Python 单元测试:pytest + coverage 覆盖率报告(含运行指令与排坑)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址