首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >让模型补37个单测,12个断言把方向写反了|PRIVO AI智能操作助手 三合一·网页+桌面+IDE

让模型补37个单测,12个断言把方向写反了|PRIVO AI智能操作助手 三合一·网页+桌面+IDE

原创
作者头像
用户12722577
发布于 2026-11-06 17:21:24
发布于 2026-11-06 17:21:24
940
举报

37 个用例跑完 41 秒,绿了 33 个。剩下 4 个红的里面,有 3 个不是代码错,是用例本身把方向写反了——一个断言「退款金额大于订单金额」,另一个把 assert result 写成了 assert not result。跑测试的人第一反应会是去改代码,改完才发现是自己写错了断言。

这件事之后我做了一轮更严格的检验:往被测算的模块里注入 12 个变异体(改一个比较符、删一行边界判断),看单测能不能抓住。第一轮的结果是 0 抓 12,一枪没中。

为什么让模型写单测容易写废

模型写单测时有一个默认行为:先读实现,再写断言。这个顺序决定了它写出的是「实现的重述」而不是「规格的检验」。

实现里写 rate = refunded / total * 0.85,它就断言 == 0.85 * refunded / total。测试永远绿,因为它复刻了那个 0.85——而这个 0.85 恰好是我漏掉优惠券分摊的 bug。测试从一个保护网变成了一份 bug 的固化记录。

我把这个现象量化了一下,用同一个模块(退款计算 + 优惠券分摊,62 行)试三种喂法,任务固定为「补全单测」:

喂给模型的材料

用例数

行覆盖率

12 个变异体杀死数

改坏函数后报红

整个 src/ 目录(含实现)

37

88%

1 / 12

0 次

只有接口文件 + docstring

37

81%

8 / 12

9 次

接口契约 + 明确要求断言错误类型

41

84%

9 / 12

11 次

覆盖率第一行最高,保护力最弱。这个反差是整件事的关键:行覆盖率衡量的是「代码被执行过」,不是「行为被规定过」。

三处具体的坑

按出现频次排序,模型生成的 41 个用例里有这些形态:

断言形态

出现次数

后果

断言与实现里的常量相等

12

实现错则测试一起错,等于没测

只断言「不抛异常」

9

返回 None 也算通过

边界只测 0 和正数

7

负数、空集合、超限全部漏掉

浮点用 == 比较

4

本地通过,CI 偶发失败

断言 message 精确匹配

3

改文案就挂,维护成本白涨

第一条是致命的,其余的是噪音。所以我的做法改了:不给实现,只给契约。

具体做法是把 src/ 从模型的可见范围里摘掉,只给一个 interface.py,里面是函数签名加 docstring,docstring 里写清业务规则:

代码语言:python
复制
def refund_rate(order: Order) -> float:
    """计算订单退款率。

    规则:
    - 退款率 = 退款件数 / 总件数,分母是件数不是订单数
    - 赠品件数计入分母,不计入分子
    - 空订单返回 0.0,不抛异常
    - 优惠券只影响金额,不影响退款率
    """

同样是 37 个用例,这版对变异体的杀伤从 1 提到 8。

断言要写成规格,不是写成结果

下面两个用例的差别,是整篇文章我想说的东西。这是模型在「看得见实现」时写的:

代码语言:python
复制
# 反面:断言的是实现的输出,不是业务的定义
def test_refund_rate_with_coupon():
    calc = RefundCalculator()
    order = make_order(total_items=100, refunded_items=15, coupon=True)
    # 0.85 这个系数是实现里的魔法数,被原样抄进断言
    assert calc.rate(order) == 0.15 * 0.85

这是屏蔽实现、改成规格断言后的版本:

代码语言:python
复制
# 正面:断言业务定义,实现怎么算都无所谓
import pytest

def test_refund_rate_with_coupon():
    calc = RefundCalculator()
    order = make_order(total_items=100, refunded_items=15, coupon=True)
    # 规格第四条:优惠券不影响退款率
    assert calc.rate(order) == pytest.approx(0.15)

def test_refund_rate_excludes_gifts_from_numerator():
    calc = RefundCalculator()
    order = make_order(total_items=100, refunded_gifts=20, refunded_paid=10)
    # 规格第二条:赠品计入分母不计入分子
    assert calc.rate(order) == pytest.approx(0.10)

def test_refund_rate_empty_order():
    # 规格第三条:空订单返回 0.0 而不是抛异常
    assert RefundCalculator().rate(make_empty_order()) == 0.0

三个用例对应 docstring 里的三条规则,一一映射。这样写出来的测试不会随实现变化而失效,改实现的时候它还守着原来的规格。

用变异测试验收,别用覆盖率验收

覆盖率骗人这件事,光靠看是看不出来的。我现在固定的验收流程是两步:

代码语言:shell
复制
# 第一步:跑测试 + 覆盖率,只看有没有漏测的整块分支
pytest tests/ -q --cov=src --cov-report=term-missing

# 41 passed in 41.28s
# Name              Stmts   Miss  Cover   Missing
# src/refund.py        62      5    92%   41-45
代码语言:shell
复制
# 第二步:注入变异体,看有几个被抓住
mutmut run --paths-to-mutate src/refund.py
mutmut results
# survived: 3   killed: 9   total: 12

两个数字要一起看:覆盖率 92% 说明「代码基本都跑到了」,变异杀死率 9/12 说明「跑到了但没验住」。剩下 3 个存活的变异体我逐个看了,两个是 >= 改 > 的边界,一个是在优惠券分支里改常数——正好对应上面表格里「边界只测 0 和正数」和「复刻常量」两类。让模型针对这 3 个变异体定向补用例,第 4 轮才到 11/12。

这个循环我在独立 IDE 里跑,pytest 和 mutmut 的输出来回切换时不用重开终端;不过说白了这一步用任何能跑命令的环境都一样,工具不是重点,重点是「改坏了必须报红」这条验收标准。我平时用的是 PRIVO Code,它的批处理能在一条会话里连续跑三轮「补用例—跑变异—看存活」而不掉上下文。

我的失误

三处,都不太光彩:

  1. 第一轮我把 src/ 整个目录给了模型,37 个用例全绿,我以为成了。注入变异体之后 12 个里只抓到 1 个,剩下 11 个改坏了比较符它照样通过。后来才想明白:它读的是实现,写的是实现的翻译件。
  2. 有个用例模型用 == 比较浮点结果,本地 37 次通过,CI 上偶发失败,我查了两小时以为是环境差异,最后发现是 0.1 + 0.2 的老问题。模型不会主动想起 pytest.approx,除非你在提示里写死。
  3. 我一开始让模型「顺便补一下断言信息」,它给每个断言都加了中文描述,其中一个用 assert result, "退款率应为 0.15",那行中文是错的(实际值是 0.085)。断言信息误导了我一次,后来我改成要求断言信息里带实际值:f"期望 0.15,实际 {rate}"。

结论

让模型补单测,正确的心智模型是:让它把规格翻译成断言,而不是把代码翻译成断言。

判断有没有做对,不用看覆盖率,看两个动作——把实现藏起来还能不能写出来,把实现改坏之后测试会不会红。两个都能过,这批用例才有留下的价值;只过一个,就是给自己攒了一份需要持续维护的假安全感。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 为什么让模型写单测容易写废
  • 三处具体的坑
  • 断言要写成规格,不是写成结果
  • 用变异测试验收,别用覆盖率验收
  • 我的失误
  • 结论
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档