把「金额输入框,0.01 到 2000 元,最多两位小数」这一句需求丢给 AI,它半分钟能吐出几十条用例,命名规整、分组清楚,看上去比你手画一下午的成果体面得多。可你从头翻到尾会发现:它测了 100、200、500、800、1000,唯独没测 2000.01;它写了金额为负的用例,却没写小数点后三位的。
这不是模型笨。是它做的事,和等价类划分、边界值分析做的事,压根不是同一件事。这两章写在 Myers 那本《软件测试的艺术》里,年头比大多数在职测试工程师都长,今天反而更该重讲一遍——不是重讲怎么手画,是重讲它在「AI 生成用例」这条流水线上该站哪个位置。
一、先把场景钉死:一个金额输入框的四条约束
不聊抽象方法论,就聊一个具体接口。电商运营后台的「优惠券批量发放」,POST /api/v1/coupon/grant,请求体里一个 amount 字段,需求文档给了四条约束:
amount 必填,单位为元,只接受数字;
有效区间 0.01 ≤ amount ≤ 2000.00;
小数最多两位,第三位直接判非法;
单笔金额严格大于 500.00 元走二级审批流,其余直接入账。
选金额字段当例子,是因为它最容易被 AI 生成得「看着很全」。取值空间连续,代表值随手就能编,编出来还都落在合理区间里,肉眼扫一遍挑不出毛病。真正的问题藏在两端和精度上——恰好是肉眼最容易滑过去的地方。
顺手把取值空间划开,只有八类:
类 ID
归类指纹
分区
代表值
期望决策
EC-V1
normal
有效
100.00
accepted
EC-V2
approval
有效
1200.50
accepted+approval
EC-I1
below-min
无效
0.00
rejected
EC-I2
above-max
无效
2000.01
rejected
EC-I3
scale-overflow
无效
0.001
rejected
EC-I4
not-a-number
无效
"abc"
rejected
EC-I5
empty
无效
""
rejected
EC-I6
missing
无效
None
rejected
注意 EC-I1 那一行:0.00、-1、-999、-0.01 全归 below-min 这一类。它们不是四条用例,是同一条用例的四种写法。这句话在手工时代是用来省时间的,在 AI 时代是用来判冗余的。
二、AI 生成的那一屏用例,问题到底出在哪
把上面那句需求原样丢给 AI,让它「生成完整的测试用例」,产出通常有四种典型症状。
症状一:同一个等价类里堆一堆。 100、200、300、500、800、1000 六条用例,归类指纹全是 normal。它们跑同一条代码路径、验同一个判断分支,多出来的五条不增加任何信息量,只增加执行时间和维护面积。
症状二:边界整体缺位。 AI 偏爱语言里的高频数字,100 和 500 是高频的,2000.01 不是。于是下界、审批线、上界这三处最容易出缺陷的位置,往往一条都没有。更隐蔽的是精度边界:100.00 和 100.01 看着差不多,但 100.001 的小数指数跨到了 -3,一跨就是另一条分支。
症状三:类型和格式类被整段跳过。 空串、None、布尔值、科学计数法字符串 1e3、带千分位的 1,000.00、前后带空格的 " 100 "——这些值一半被前端拦掉、一半被网关拦掉,剩下的漏到业务层。而 AI 生成的用例集里,这一大片通常只有一条 "abc"。
症状四:断言空心化。 无效用例只断言状态码不等于 200,甚至只断言「不抛异常」。可 400 和 500 是两件事,拒绝原因是 below-min 还是 scale-overflow 也是两件事。断言里没有原因码,这条用例就测不出回归——将来有人误删了精度校验,用例照样全绿。
四种症状同一个根因:AI 在续写「看起来像测试用例的文本」,不在划分取值空间。它手上没有你的约束模型,只有语言习惯。你不给它坐标系,它就只能按语感撒点。
三、等价类的新身份:从「省用例的工具」变成「喂给 AI 的约束」
手工时代,等价类的价值是减法——把无穷的取值压成八个代表值,让人少写点用例。AI 时代它多了一层价值,而且是更值钱的一层:它做加法,加在生成过程的约束上。
因为等价类表一旦写成结构化数据,它就同时是三个东西:
给 AI 的 prompt 输入。 把这张表贴进去,明确要求「每个类 ID 至少一条、同类不超过两条」,生成结果的分布立刻从「语感撒点」变成「按格填空」。
参数化用例的数据源。 表改了用例自动跟着改,不必去几十个测试函数里手动改常量。
事后审计的分桶依据。 AI 吐回来的每一条用例都能被归到某个类 ID 上,覆盖没覆盖、冗余不冗余,一眼可算,不用开会吵。
一物三用,这才是等价类今天还值得画的真正理由。它不是要你回去手抄用例,是要你把脑子里那张图变成机器能读的约束。
四、把等价类表写成代码,再让它长成参数化用例
第一步,把约束和归类逻辑固化成一份可导入的规格文件。这里附一个最小可跑的校验替身,真实项目里把它换成你的接口调用或前端校验函数即可:
spec/amount_spec.py
金额字段的取值约束:等价类表 + 边界值表 + 归类指纹
这张表同时是三样东西:给 AI 的约束、参数化用例的数据源、审计脚本的分桶依据
from decimal import Decimal, InvalidOperation
MIN_AMOUNT = Decimal("0.01")
MAX_AMOUNT = Decimal("2000.00")
APPROVAL_LINE = Decimal("500.00") # 严格大于才走二级审批
等价类表:(类ID, 归类指纹, 分区, 代表值, 期望决策)
EQUIVALENCE_CLASSES = [
("EC-V1", "normal", "valid", Decimal("100.00"), "accepted"),
("EC-V2", "approval", "valid", Decimal("1200.50"), "accepted+approval"),
("EC-I1", "below-min", "invalid", Decimal("0.00"), "rejected"),
("EC-I2", "above-max", "invalid", Decimal("2000.01"), "rejected"),
("EC-I3", "scale-overflow", "invalid", Decimal("0.001"), "rejected"),
("EC-I4", "not-a-number", "invalid", "abc", "rejected"),
("EC-I5", "empty", "invalid", "", "rejected"),
("EC-I6", "missing", "invalid", None, "rejected"),
]
边界值表:每个边界取「上一点 / 边界 / 下一点」
BOUNDARIES = [
("下界", [Decimal("0.00"), Decimal("0.01"), Decimal("0.02")]),
("审批线", [Decimal("499.99"), Decimal("500.00"), Decimal("500.01")]),
("上界", [Decimal("1999.99"), Decimal("2000.00"), Decimal("2000.01")]),
("小数精度", [Decimal("100.00"), Decimal("100.01"), Decimal("100.001")]),
]
class Decision:
"""校验结果 = 决策 + 可追溯原因码。原因码是给审计脚本用的,不能省。"""
def __init__(self, decision: str, reason: str = ""):
self.decision = decision
self.reason = reason
def fingerprint(raw) -> str:
"""归类指纹:类型 -> 格式 -> 精度 -> 区间,四段决定它落在哪个等价类"""
if raw is None:
return "missing"
if isinstance(raw, str) and not raw.strip():
return "empty"
try:
value = Decimal(str(raw))
except InvalidOperation:
return "not-a-number"
if not value.is_finite(): # 拦住 NaN / Infinity
return "not-a-number"
if value.as_tuple().exponent < -2: # 小数位超过 2 位
return "scale-overflow"
if value < MIN_AMOUNT:
return "below-min"
if value > MAX_AMOUNT:
return "above-max"
if value > APPROVAL_LINE:
return "approval"
return "normal"
def validate_amount(raw) -> Decision:
"""被测校验逻辑的最小可跑替身:真实项目换成你的接口或前端校验"""
fp = fingerprint(raw)
if fp == "normal":
return Decision("accepted", fp)
if fp == "approval":
return Decision("accepted+approval", fp)
return Decision("rejected", fp)
第二步,让 pytest 从这张表里长出用例,而不是把数据写死在测试函数里:
tests/test_amount.py
从等价类表和边界值表长出参数化用例:数据不写死在测试函数里
import pytest
from spec.amount_spec import (
BOUNDARIES, EQUIVALENCE_CLASSES, fingerprint, validate_amount,
)
def ec_cases():
"""等价类 -> 参数化数据:类 ID 进用例名,报告里看得见覆盖了哪一格"""
return [
pytest.param(value, expected, id=f"{ec_id}-{fp}")
for ec_id, fp, _partition, value, expected in EQUIVALENCE_CLASSES
]
def boundary_cases():
"""边界值 -> 参数化数据:三点法,一个边界展开成三条"""
return [
pytest.param(point, id=f"{name}-{point}")
for name, points in BOUNDARIES
for point in points
]
@pytest.mark.parametrize("amount, expected", ec_cases())
def test_equivalence_classes(amount, expected):
"""每个等价类必须落到它被声明的那个决策上"""
assert validate_amount(amount).decision == expected
@pytest.mark.parametrize("amount", boundary_cases())
def test_boundaries_are_decided(amount):
"""边界用例的断言不是「不报错」,而是「必须给出明确决策 + 原因码」"""
result = validate_amount(amount)
assert result.decision in ("accepted", "accepted+approval", "rejected")
assert result.reason, "边界值必须带原因码,否则测不出回归"
assert result.reason == fingerprint(amount), "原因码必须与归类指纹一致"
def test_table_is_self_consistent():
"""自检:表里每个代表值必须真落在它声明的那个类上。表错了,用例全白搭。"""
for ec_id, fp, _partition, value, _expected in EQUIVALENCE_CLASSES:
assert fingerprint(value) == fp, f"{ec_id} 的代表值 {value!r} 归类不符"
这么写的好处,第一是改动成本。产品把上限从 2000 调到 5000,你改一个常量,等价类代表值、边界三点、参数化用例、审计基线全部同步。手工时代你要在 Excel 里翻出二十七处「2000」,还分不清哪处是需求、哪处是笔误。
第二是报告可读性。pytest -v 打出来的用例名直接带类 ID 和边界点名,test_boundaries_are_decided[上界-2000.01] 红了,你不翻代码就知道是哪一格塌了。
五、边界值的新身份:验收 AI 输出的那把尺子
等价类管生成,边界值管验收。分开做是因为它们的失败模式不同:等价类没划好,你会漏一整片;边界值没量过,你以为覆盖了其实没有。
边界值在 AI 时代的用法,是做成一把能自动卡流水线的尺子。AI(或者任何自动生成器,包括你们内部那个用例工厂)交回一份 JSON,脚本按三点法逐个比对:
tools/audit_ai_cases.py
用途:把 AI 生成的用例(JSON)拿等价类表和边界值表量一遍
判定:等价类是否全覆盖、同类是否冗余、边界点是否被精确命中
挂进 CI:不达标直接非零退出,AI 用例集不允许入库
运行:PYTHONPATH=. python tools/audit_ai_cases.py ai_generated.json
import json
import sys
from collections import Counter
from decimal import Decimal, InvalidOperation
from spec.amount_spec import BOUNDARIES, EQUIVALENCE_CLASSES, fingerprint
MAX_PER_CLASS = 2 # 同一等价类超过 2 条即判冗余
def load_ai_cases(path: str):
"""约定 AI 按 {"cases": [{"amount": ...}]} 交付——交付格式本身也是契约的一部分"""
with open(path, "r", encoding="utf-8") as f:
payload = json.load(f)
return [case.get("amount") for case in payload["cases"]]
def to_decimal(value):
try:
return Decimal(str(value))
except InvalidOperation:
return None
def audit(path: str = "ai_generated.json") -> int:
values = load_ai_cases(path)
counter = Counter(fingerprint(v) for v in values)
# 刻度一:等价类覆盖——有没有整类没被测到
missing_ec = [
f"{ec_id}({fp})" for ec_id, fp, *_rest in EQUIVALENCE_CLASSES
if counter.get(fp, 0) == 0
]
# 刻度二:边界命中——三点法里的每个点是否被精确取到
required = {point for _name, points in BOUNDARIES for point in points}
given = {d for d in (to_decimal(v) for v in values) if d is not None}
missing_boundary = sorted(required - given, key=str)
# 刻度三:冗余度——只数「非边界点」。三点法本身就会让同一个类里出现多个边界值,
# 把它们算进冗余会误判;冗余要盯的是 AI 在区间内部随手堆的那些舒服值。
inner = Counter(fingerprint(v) for v in values if to_decimal(v) not in required)
redundant = {fp: n for fp, n in inner.items() if n > MAX_PER_CLASS}
print(f"AI 用例总数:{len(values)}")
print(f"未覆盖等价类:{missing_ec or '无'}")
print(f"冗余等价类(>{MAX_PER_CLASS} 条):{redundant or '无'}")
print(f"漏掉的边界点:{[str(p) for p in missing_boundary] or '无'}")
if missing_ec or missing_boundary:
print("[BLOCK] 等价类或边界点未覆盖,AI 用例集不可入库")
return 1
if redundant:
print("[WARN] 存在冗余,建议抽样保留后降级进回归集")
print("[OK] 等价类全覆盖、边界点全命中")
return 0
if name == "main":
sys.exit(audit(sys.argv[1] if len(sys.argv) > 1 else "ai_generated.json"))
尺子的三个刻度,对应三种不合格:
刻度
检查什么
不合格时长什么样
处置动作
等价类覆盖
八个类 ID 是否每类至少一条
整类为 0,通常是 missing、scale-overflow
打回,要求按类补齐再交
冗余度
同一类 ID 是否超过 2 条
normal 类里堆了六条整数金额
抽样保留,其余降级进回归集
边界命中
十二个边界点是否被精确取值命中
有 1999.99,没有 2000.01
直接判不可入库
第三条最硬。边界点上「差不多」等于「没测」:1999.99 和 2000.01 之间隔着一条 > 还是 >= 的判断,隔着一次线上资损。AI 生成的用例集里只要少一个边界点,这份集合就不能算已覆盖——不管它总共有一百条还是三百条,条数在这个判断面前完全没有发言权。
六、三种做法摆在一起看
同一份需求,三种做法。差别不在用例条数,在下面这几个维度上:
维度
纯手工设计
纯 AI 生成
等价类约束下的 AI 生成
覆盖完整性
取决于人的经验和当天状态,类型/格式类易漏
高频值密集,边界与异常类型系统性缺失
按类填空,八类强制到位,边界由脚本兜底
冗余度
低,人会自觉合并同类
高,同一等价类反复堆代表值
可控,同类超过 2 条即被审计标红
可验收性
靠评审会和人眼,结论不可复现
几乎没有抓手,「看着挺全」就是结论
尺子是代码,通过与不通过可复现、可进 CI
需求变更成本
高,用例散落在文档与脚本各处
中,重新生成一次,但老问题会原样重现
低,改常量即改全套,基线同步更新
人的时间投向
抄写、排版、维护用例文本
逐条阅读、逐条怀疑
划等价类、定边界、写验收规则
最后一行才是重点。等价类和边界值并没有把人从这件事里替掉,它把人从「写用例」挪到了「定义什么叫做完了」。前者是体力,后者是判断力,而判断力恰好是 AI 现在交不出来的那部分。
顺带算一句成本账:约束这一层的前期投入,是一张表加一个百来行的审计脚本。它换来的是每一次生成结果都能被自动判定合格与否——从第二个迭代开始,你不用再逐条读 AI 的输出,这笔投入就回来了。
七、写在最后
AI 把用例的产出成本压到了接近于零,同时把用例的可信成本抬到了从来没这么高。生成一百条不难,难的是说清楚这一百条覆盖了什么、没覆盖什么、凭什么算够。
等价类是给生成器的坐标系,边界值是给验收者的刻度尺。前者决定 AI 往哪儿撒点,后者决定你敢不敢签字。两张表都不复杂,复杂的是很多团队在有了 AI 之后,把它们当成过时的手工活儿扔了——扔掉的不是画图这件事,是唯一能让「AI 生成的用例」变成「可交付的测试资产」的那层约束。
用例可以被生成,覆盖不能。能被生成的那部分交给 AI,不能被生成的那把尺子,还得你自己刻。
你们组的 AI 生成用例是怎么验的,有没有一把能进 CI 的尺子?留言区聊聊。