等价类和边界值,在 AI 一键生成用例的时代还值不值得手画

举报
霍格沃兹测试开发学社 发表于 2026/09/13 12:14:25 2026/09/13
【摘要】 把「金额输入框,0.01 到 2000 元,最多两位小数」这一句需求丢给 AI,它半分钟能吐出几十条用例,命名规整、分组清楚,看上去比你手画一下午的成果体面得多。可你从头翻到尾会发现:它测了 100、200、500、800、1000,唯独没测 2000.01;它写了金额为负的用例,却没写小数点后三位的。这不是模型笨。是它做的事,和等价类划分、边界值分析做的事,压根不是同一件事。这两章写在 M...
把「金额输入框,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[1if 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 的尺子?留言区聊聊。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。