一、三百八十条用例,评审会没人提重复
一个合同审查 RAG 系统的用例库,三百八十条。迭代评审会,四个人,两小时,议题是这一版覆盖够不够。
讨论集中在扫描件畸变要不要加、多页合同的页码识别要不要单独测。我中途插了一句:这三百八十条里,有多少是重复的?
安静了几秒。有人说,应该没有吧,都是按需求写的。
这句话几乎是所有测试团队的默认假设:用例是按需求一条条写的,写的时候看需求,不看用例库,所以不会重复。
后来我用一段脚本跑了一遍:96 条疑似重复,落在 41 个簇里,涉及 137 条用例。
同一场评审的记录里,人工标出的重复是 11 条。
差了将近九倍。我第一反应是“AI 是不是太激进”,把 96 条逐条看了两天,结论是它没错。举一个簇:
-
合同金额为零时提交应被拒绝
-
零金额合同无法进入审核流程
-
0 元合同提交后状态不变,仍为草稿
三条标题,三种说法,断言完全一样:status 等于 400,且 error_code 等于 AMOUNT_INVALID。三条加起来执行 18 分钟,验证的是同一件事。
二、三分之二的重复,标题长得完全不一样
96 条里,63 条标题相似度低于 0.4,断言相似度高于 0.9。三分之二的重复,标题长得完全不一样。
人是按标题找重复的。标题是故事的入口,读用例先读标题,判断“这条见过没有”也在标题层面完成。机器比的是契约,而断言才是契约。
这个错位解释了 11 和 96 的差距,也让“人工评审就够了”站不住:不是人不认真,是人用的索引不对。
重复的代价有两块。
一块是时间。380 条乘平均 6 分钟,一轮 38 小时。96 条重复占 9.6 小时,双周一轮,一年 26 轮,250 小时,大约 31 人日。这 31 人日不产生任何新信息。
另一块更隐蔽:通过率虚高。380 条里通过 342 条,90.0%。剔掉 96 条重复后,284 条里通过 246 条,86.6%。差 3.4 个百分点。
3.4 个点不算多,但它稳定地让每个版本看起来更好一点。重复的大多是正常路径,跑得快、挂得少,天然拉高均值;边界和异常用例本来就不爱通过,被这些必过的用例一平均,报表就漂亮了。
重复还会自我繁殖。新增用例的人看的是标题,看到“没有类似的”就写了,于是又造一条。三个月里用例从 240 涨到 380,新增 140 条,其中 71 条在这 96 里——超过一半的新增没带来新覆盖。
所以我的判断是:重复率比覆盖率更值得先解决。覆盖率是加法,重复是乘法,每加一条重复用例,都在稀释已有用例的信号。那三个月团队把覆盖率从 72 做到 91,如果先做去重,同样的时间不止 91,执行成本还能低三成。
三、人工看标题、AI 直接删、AI 预筛加人工判定
三条路。
| 方案 | 做法 | 失败模式 | | --- | --- | --- | | A 纯人工评审 | 两小时会议逐条过,靠标题和经验判断 | 只看标题,96 条只能发现 11 条;第 90 分钟后基本无人发言 | | B AI 全量评审直接删 | 相似度 0.85 以上自动合并 | 误删跨分支用例:两条断言都是“拒绝”,一条金额为零,一条金额为负,错误码不同 | | C AI 预筛 + 人工判定 | AI 出候选对和相似度,人只判 41 个簇 | 判定要 40 分钟;需要懂业务分支,只做执行的人判不了 |
A 的失败很具体:前 60 分钟还能逐条看,后 60 分钟基本在念标题。那 11 条就是这么来的——不是没看,是看到第 200 条之后,眼睛已经不在断言上了。
B 我们试了一周,自动合并 0.85 以上的,删了 96 条,很爽。两周后线上出问题:负数金额的错误码返回成了 AMOUNT_INVALID,应该是 AMOUNT_NEGATIVE。追下去发现,那条用例被当成重复删了——它和“金额为零”那条相似度 0.94,但测的是另一个分支。
相似不等于重复。相似度是 0 到 1 的连续值,去留是二元的决定,中间必须有人。
C 是我选的:AI 不做决定,只做排序和呈递。它把 41 个簇摆出来,每簇给相似度和差异点,人只判留不留。评审会从两小时压到 40 分钟。
四、别按标题聚类,按断言聚类
第一步是把聚类单位从标题换成断言。
python
import re
from dataclasses import dataclass
from typing import FrozenSet, Tuple
@dataclass(frozen=True)
class Case:
cid: str
title: str
input_text: str
assertion: str
ASSERT_RE = re.compile(
r"(?P[A-Za-z_][\w\.\[\]]*)\s*(?P==|!=|>=|<=|>|<|contains|in)\s*(?P.+)"
)
# 只归一化标识类值:UUID、长数字串(订单号、时间戳)
ID_PATTERNS = [
(re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I), ""),
(re.compile(r"\b\d{8,}\b"), ""),
]
def normalize_value(v: str) -> str:
v = v.strip().strip("\"'")
for pat, tag in ID_PATTERNS:
v = pat.sub(tag, v)
return re.sub(r"\s+", "", v).lower()
def normalize_assertion(text: str) -> FrozenSet[Tuple[str, str, str]]:
"""把断言拆成 (字段, 运算符, 值) 三元组集合,忽略顺序。"""
triples = set()
for raw in re.split(r"[;;\n]|(?:\band\b)|&&", text):
m = ASSERT_RE.search(raw.strip())
if not m:
continue
field = m.group("field").strip().lower()
op = m.group("op").lower()
triples.add((field, op, normalize_value(m.group("value"))))
return frozenset(triples)
def jaccard(a: FrozenSet, b: FrozenSet) -> float:
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
断言是契约,标题是叙述。同一件事可以有十种叙述,契约只有一种写法。
踩过的第一个坑是值的归一化。断言里带具体值,两条用例的值不同,jaccard 直接是 0。我们的做法是只归一化标识类值——订单号、时间戳、UUID,换成占位符;语义类值必须保留,错误码、状态、枚举一个都不能动。
这条界线很关键。归一化过头,“金额为零”和“金额为负”会被合成一条,正好是方案 B 翻车的那类;归一化不足,同一个断言换个订单号就被判成两条。
第二个坑是断言写法五花八门:resp.status == 400、status 应为 400、HTTP 400。我们最后只支持两种,代码式断言和“字段加运算符加值”的自然语言断言。其余让作者改,改不了的不参与聚类——宁可漏,不可错。
五、相似度要是个分数,不是个开关
第二步是给相似度一个连续的分,不是一个是非判断。
python
from difflib import SequenceMatcher
def ratio(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
# 断言、输入、标题三档权重,按用例类型配置
WEIGHTS = {
"api": (0.60, 0.25, 0.15),
"ui": (0.75, 0.00, 0.25),
}
def dup_score(a: Case, b: Case, kind: str = "api") -> float:
w_assert, w_input, w_title = WEIGHTS.get(kind, WEIGHTS["api"])
s_assert = jaccard(normalize_assertion(a.assertion), normalize_assertion(b.assertion))
s_input = ratio(a.input_text, b.input_text) if w_input else 0.0
s_title = ratio(a.title, b.title)
return round(w_assert * s_assert + w_input * s_input + w_title * s_title, 3)
def bucket(score: float) -> str:
if score >= 0.90:
return "几乎是同一条"
if score >= 0.75:
return "很可能重复"
if score >= 0.60:
return "需要人看一眼"
return "不用管"
0.6、0.25、0.15 是调出来的。第一版三项均分,结果“标题很像但断言不同”的对被排到最前面——恰恰是最危险的一类,人一看标题一样就点了合并。把标题压到 0.15 之后,排序质量肉眼可见地变好。标题只该做微调,不该做主判据。
踩坑:SequenceMatcher 对中文长句不敏感。UI 用例的输入是操作步骤,两条几乎不重合,输入项恒在 0.1 左右,这一项等于白给。所以权重做成了按类型可配,UI 用例直接把输入项权重归零,加到断言上。
配置本身也有风险:配错了排序就错,而人不会怀疑排序。我们每季度拿 50 对标注复核一次,前 20 对准确率低于 80 就重调。
六、把候选对摆成人能判断的样子
第三步是生成清单。人不需要算法,人需要一张能快速扫的纸。
python
def diff_summary(a: Case, b: Case) -> str:
sa = normalize_assertion(a.assertion)
sb = normalize_assertion(b.assertion)
only_a = sorted(sa - sb)
only_b = sorted(sb - sa)
parts = []
if only_a:
parts.append("A 独有 " + "; ".join(f"{f}{o}{v}" for f, o, v in only_a))
if only_b:
parts.append("B 独有 " + "; ".join(f"{f}{o}{v}" for f, o, v in only_b))
return " | ".join(parts) if parts else "断言完全一致"
def build_checklist(cases, kind="api", min_score=0.60, max_pairs=60):
pairs = []
for i in range(len(cases)):
for j in range(i + 1, len(cases)):
s = dup_score(cases[i], cases[j], kind)
if s >= min_score:
pairs.append((s, cases[i], cases[j]))
pairs.sort(key=lambda x: -x[0])
pairs = pairs[:max_pairs]
lines = ["| 相似度 | 建议 | A | B | 差异 |", "|---|---|---|---|---|"]
for s, a, b in pairs:
if s >= 0.90:
rec = "合并"
elif s >= 0.75:
rec = "人工判定"
else:
rec = "保留"
lines.append(
f"| {s:.3f} | {rec} | {a.cid} {a.title} | {b.cid} {b.title} | {diff_summary(a, b)} |"
)
return "\n".join(lines)
为什么限制 60 对:一次给人看 340 对,等于没看。60 对大约 40 分钟,正好一场评审会,超出的下一轮。第一版不限量,跑出来 340 对,评审会看了 20 对就散了。
差异列是后来加的,加完之后判定速度快了一倍。没有它,人得回去把两条用例都读一遍;有了它,一眼看到“B 多了一条 AMOUNT_NEGATIVE”,直接判保留。
建议那列我犹豫过:加了会诱导人直接采纳,不加又要自己算。最后分档:0.9 以上写合并,0.75 到 0.9 写人工判定,0.6 到 0.75 永远不给建议。这一档误判代价最高。
七、删掉九十六条之后,通过率掉了三个点
| 指标 | 改造前 | 改造后 | | --- | --- | --- | | 用例总数 | 380 | 284 | | 单轮执行时长 | 38 小时 | 28.4 小时 | | 评审会时长 | 2 小时 | 40 分钟 | | 整体通过率 | 90.0% | 86.6% | | 回归漏检 | 未统计 | 0 | | 新增用例重复率 | 51% | 5% |
通过率掉 3.4 点是好事,分母干净了。这话我在评审会上说过一次,被开发怼了:那你们以前报的 90% 是假的?我说不假,但它衡量的是这 380 条里过了多少,不是这个系统有多可靠。
漏检 0 这个结论只对那 96 条成立。每条重复的断言都在保留用例里存在,所以删掉不丢覆盖。但如果两条只是“几乎”一致,差一个分支,删了就会漏,这就是方案 B 的死法。所以清单里凡是差异列不为空的,一律人工判定。
新增重复率从 51% 降到 5%,靠的不是 AI,是把去重挪到了提交前:新用例入库先跑一遍相似度,超过 0.75 提示“库里有 2 条相似用例,请说明差异”。说明不了,不许入库。
这个门禁刚上线时被骂了两周;后来大家发现,写差异说明的过程,往往就是发现自己写重复了。
八、你真的需要三百八十条吗
先回答一个我常被问到的问题:两条用例断言一样、只是输入不同,算重复还是参数化?
我的答案:参数化能表达的,就不是两条用例。但有个例外——输入差异本身就是测试目的时(比如一组边界值),拆开写可读性更好,合成参数化反而没人看得懂。这个判断只能人做。
这套方法在三种情况下会失效。
断言写得含糊。“结果正确”、“返回成功”这种断言没法比对,聚类会把所有含糊断言聚成一坨,然后建议你删掉一半。这类用例得先补断言,再谈去重。
业务上确实需要多条相似用例。合规系统很常见:同一个校验,监管要求每个条款单独留证。这时候重复是需求,不是问题,去重前先问业务。
用例库太小。80 条以下的库,两两比对 3160 对,人工看得完,聚类的价值不如直接开会。
阈值也是按这个项目调的,换个业务领域,0.75 可能是“完全不同的两条”。必须用 50 到 100 对人工标注校准一次,跳过这步,前面的分数都不可信。
你也可以拿三个问题问自己的团队。最近三个月新增的用例里,有多少条在库里能找到相似的?评审记录里出现过“删除用例”吗,还是只有“补充用例”?如果两条用例的断言只差一个错误码,你判重复还是判两条?
小结
-
人审用例会漏重复,因为索引是标题,而重复藏在断言里。
-
380 条里 96 条重复,人工只发现 11 条,差了近九倍。
-
重复不只浪费时间,还让通过率虚高 3.4 个点,而且会自我繁殖。
-
相似度是连续值,去留是二元决定,中间必须有人。
-
差异列比相似度更重要,它决定了人能不能在 40 分钟内判完。
文章原创首发于微信公众号 软件测试微课堂,更多内容欢迎关注微信公众号查看
(注:文中视频或者截图部分来自网络)
----软件测试微课堂的公众号----------------
以上为正文,来自pujenyuan,原创首发认准【软件测试微课堂】