不得不说,第一次把 AI 接进测试用例生成的时候,我是有点兴奋的。输入一段需求描述,几十条用例唰一下就出来了,格式工整、步骤齐全,看起来很专业。但等我把这批用例交给测试组评审,人家看完第一页就皱眉了:这个“密码错误”的用例你已经写了五遍了,字不一样,逻辑是一模一样的。
我回去统计了一下,第一批用 GPT-4 生成的 60 条用例里,有明显重复语义的大概有 25 条左右,其中字面几乎一样的就有 12 条。换句话说,模型非常勤奋地帮我们把一条用例翻译成了各种说法。
这个问题不解决,AI 生成测试用例的效率优势会被人工筛选成本完全吃掉。所以后来我花了大概三周时间,专门做了一件事:让 AI 生成的测试用例不重复。这篇文章把我这次改造中用到的策略、算法、提示词以及踩过的坑都整理出来,希望对正在做同样方向的 QA、测试开发以及 AI 应用开发者有帮助。
1. 重复问题出在哪:先理解AI为什么会造出“看起来不一样”的重复用例
1.1 AI不是“记不住”,它压根没有一张用例清单
先说个容易被忽略的事实:绝大多数大语言模型在做生成的时候,并没有一个真正的“已生成用例清单”可以查询。它的工作方式是逐 token 预测概率,输入一段需求文本,模型在语义空间里按概率采样生成内容。你第一轮问它“请为登录功能生成用例”,第二轮再问一次,只要有细微的随机性,它给出的第 7 条用例和第 1 轮的第 7 条用例就可能长得完全不同。
这跟人写用例是两回事。一个人写测试用例时,脑子里会有一个隐形的检查单:正常流程、异常流程、边界值、必填项校验……写完一条就会在清单上勾一下,避免下一轮再踩同样的分支。但模型没有显式维护这个清单,它靠的是训练时学到的“回答这类问题通常要覆盖哪些点”,而不是“当前上下文里我已经覆盖了哪些点”。
所以,当模型连续输出多条用例时,它其实是在同一个语义空间里不断“复述”它认为重要的场景。温度参数调得越高,句子形态差异越大,但底层测的都是同一段业务逻辑。这就是重复用例的第一个根源:无状态生成。
1.2 “字面不同”不等于“逻辑不同”:等价类视角的重复
第二个根源更隐蔽,也更关键:AI 生成的重复,绝大多数不是字符串级别的重复,而是等价类级别的重复。举一个特别经典的例子,被测功能是一个“手机号输入框”,规则是 11 位数字。AI 可能同时生成这样两条用例:
- 用例 A:输入 11 位数字“13812345678”,系统校验通过。
- 用例 B:手机号输入 11 位有效数字,正常提交。
这两条句子在文本层面几乎没有任何共同点,哪怕你用最严格的文本相似度算法,得分也低得可怜。但是在测试设计视角下,这两条用例执行的是同一条代码路径:合法等价类通过。
这就是等价类划分的思想。我们在手工设计用例时,会把输入数据划分成若干个等价类:有效等价类测一个代表值就够了,无效等价类里的每种错误类型测一个代表值就够了。但大模型没有这种经济性意识,它在训练语料里看到过各种“手机号正确”的不同表述,于是它倾向于把这些表述全部生成出来,好显得“覆盖全面”。
因此在做 AI 生成用例的去重时,如果只做文本判重,基本是白做。你必须先意识到:重复不是文案重复,而是业务逻辑等价类重复。
1.3 组合空间大不代表都要测:伪覆盖与正交设计
前两类重复已经很难处理了,第三类更让人头疼,叫“伪覆盖”。当被测功能有多个输入条件时,AI 特别热衷于穷举组合。比如一个找回密码功能涉及手机号、验证码、新密码三个输入项,AI 会生成下面这一堆用例:
- 手机号正确 + 验证码正确 + 新密码符合规则
- 手机号错误 + 验证码正确 + 新密码符合规则
- 手机号正确 + 验证码错误 + 新密码符合规则
- 手机号正确 + 验证码正确 + 新密码为空
表面上看,这是四条不同的用例,组合各不一样。但从测试成本的角度看,很多组合没有独立价值。比如“手机号错误”和“验证码错误”分别触发的是独立校验逻辑,它们不需要和第三项组合在一起成为新用例。如果按照全排列去生成,一个只有 3 个条件、每个条件 4 种情况的输入域,就能组合出 64 条用例,而其中绝大多数都是重复覆盖同一段分支判断的“伪覆盖”。
这也是我后来在提示词里刻意加入“正交实验设计”、“边界值分析”思想的原因。让 AI 按条件粒度去生成用例,而不是按条件组合去生成用例,能从源头上大幅降低用例数量,并且不会丢失覆盖率。
2. 把去重前置:提示词层面的三层约束
2.1 防重Prompt第一件事:把“重复”定义写清楚
很多人以为在提示词里加一句“不要生成重复的测试用例”就够了。实际上,大模型对“重复”的理解和测试工程师对“重复”的理解完全不一样。你说“不要重复”,它理解的可能是不要生成格式一模一样的句子,于是它反而会努力换措辞,让重复变得更隐蔽。
我建议在提示词里给模型一个可操作的重复判定标准。这个概念最早是我在要求手下的测试工程师评审 AI 用例时总结出来的,后来直接写进了 Prompt。下面是我实际在用的一个版本:
判定两条测试用例是否重复,按以下优先级执行: 1. 如果两条用例操作的是同一个页面元素或接口字段,且输入数据的类型相同 (例如都是“长度不足”或“格式非法”),则视为重复。 2. 如果两条用例的执行步骤序列完全相同,只有操作数据的具体取值不同, 且该取值属于同一个等价类,则视为重复。 3. 如果两条用例触发的是同一个错误提示或同一个异常分支,则视为重复。 4. 如果两条用例的差异仅仅体现在描述语气、措辞、大小写或中英文表达上,则视为重复。注意这个定义里出现了“等价类”“分支”这些词,它们都是测试领域的专业概念,模型是能理解的。你把定义写得越接近测试工程师的评审语言,模型输出的重复率就越低。
2.2 给每条用例打上“等价类标签”,让模型按边界值去发散
第二条约束是我认为全流程里收益最高的一步:让 AI 在生成每条用例时,显式输出一个等价类标识符。
等价类标识符可以理解成给用例盖一个章,标注它覆盖的是哪条业务规则和哪个边界。例如登录模块可以定义如下标签体系:
- AUTH-LOGIN-VALID:正常凭证登录成功
- AUTH-LOGIN-EMPTY-USER:用户名为空
- AUTH-LOGIN-EMPTY-PASS:密码为空
- AUTH-LOGIN-WRONG-PASS:密码错误
- AUTH-LOGIN-LOCKED:账号被锁定
- AUTH-LOGIN-DISABLED:账号已禁用
我在提示词里要求:模型生成用例前,必须先根据需求拆解出等价类清单,然后再针对每个等价类生成用例,最后在每条用例的“等价类ID”字段里标注它属于哪一类。生成结束后还要做一轮自检:如果两条用例的等价类ID相同且动作序列相同,就删除其中一条。
这样做的好处不只是便于后处理去重,更重要的是让模型在生成阶段就建立“归类意识”。它不再是随意地往语义空间撒点,而是先规划再填充。实测下来,光加这一步,同一批需求的语义重复率就下降了差不多一半。
2.3 让输出可编程:JSON Schema + 结构化字段
如果说前两步解决的是“模型不知道什么是重复”,那第三步解决的是“就算模型知道,你怎么在代码里拿到它”。测试用例天然是高度结构化的数据,包含前置条件、操作步骤、测试数据、预期结果、优先级等字段。如果让 AI 自由发挥生成大段散文,后处理阶段的人工解析成本和误判率都会非常高。
我强烈建议用 JSON Schema 强制约束输出结构。在代码层面我一般会用函数调用或输出格式约束,确保每条用例都返回固定 JSON 结构。下面是我常用的一份简化版 schema:
{ "用例列表": [ { "用例ID": "TC-LOGIN-001", "等价类ID": "AUTH-LOGIN-WRONG-PASS", "所属功能模块": "登录", "前置条件": "已进入登录页面", "操作步骤": ["输入已注册手机号", "输入错误密码", "点击登录"], "测试数据": {"手机号": "13800138000", "密码": "wrong_pass"}, "预期结果": "页面提示“密码错误,您还可以尝试4次”", "优先级": "高" } ] }这里有个很重要的设计细节:不要只给 AI 一个 JSON 模板,要同时给一个字段填写说明。尤其是“等价类ID”这个字段,AI 第一次很可能不知道怎么写,你需要提供几个示例,明确告诉他这个值不是随便编的,而是前面拆解需求时列出的等价类清单中的一项。有了结构化输出,后处理阶段就可以直接对字段做判重,而不是把整段文字丢给算法。
3. 特征计算与相似度判重:从字符串到业务语义
即便提示词设计得再好,也不可能把重复率降到零。模型偶尔还是会突破约束,生成一些语义高度相似但没有显式标记的用例。这时候就需要一套后处理判重机制,对生成结果做第二道甚至第三道防线。
3.1 文本层粗暴去重:归一化、MinHash与simHash
很多人做文本去重会想到各种 Hash 算法,但用在测试用例上有几个坑,我一个个说。
首先是归一化。在做任何计算之前,一定要先清洗文本。测试用例里的中文标点、全角半角空格、大小写、括号种类都会干扰相似度计算。我在项目里写过一批归一化规则,核心包括:把全角字符转半角、把中文逗号句号统一成英文分隔符、删除所有空格、把“账号”和“账户”这一类同义词替换为统一词根、把手机号/身份证号等具体值替换为占位符。
归一化做完之后,如果你只是想去掉“几乎一模一样的重复”,用 simHash 就够了。它的思路是把文本映射成固定位数的指纹,然后比较两个指纹的海明距离。但 simHash 对短文本效果不稳定,测试用例通常也就几行字,很容易出现“分母太小、指纹抖动大”的情况。
我在实际项目里更喜欢用 MinHash + Jaccard 相似度来兜底处理操作步骤这一类短文本。思路是:把用例的操作步骤序列拆成 n-gram,例如把输入手机号、点击登录拆成二元组,然后计算两个集合的交集比例。n-gram 的 n 需要根据步骤数量调整,步骤一般很短,n 取 2 比较合适。下面是一个很简洁的实现:
from datasketch import MinHash, MinHashLSH def build_minhash(text, num_perm=128): tokens = text.split(" ") # 假设已经做了归一化和分词 m = MinHash(num_perm=num_perm) for t in tokens: m.update(t.encode("utf-8")) return m不过要提前说明,文本层算法能抓的是“字面上就很像”的重复,真正棘手的语义重复还得靠下面几层。
3.2 语义层判重:Embedding相似度的取舍
对归一化之后仍然“字面不同”但语义相同的用例,文本算法无能为力,这时我会用一个嵌入模型把用例文本向量化,然后计算两个向量之间的余弦相似度。
这里有一个经验:不要直接对整条用例做向量化。整条用例通常包含前置条件、步骤、数据、预期结果四个部分。步骤部分和预期结果部分在语义空间中是两种完全不同的表达,混在一起算相似度,会把分数拉偏。比如两条用例,前置条件都是“用户已登录”,这个片段占整条文本长度很大比例,结果两条用例可能因为前置条件相同,余弦相似度就偏高,尽管核心操作完全不同。
解决办法是分字段计算。我更推荐把操作步骤和预期结果拆开,分别向量化,然后取加权分数。操作步骤相似度权重给 0.6,预期结果给 0.4。因为在实际测试设计中,只要操作路径一致,预期结果是否同一句话反而是次要的。
from openai import OpenAI client = OpenAI() # 初始化你的模型客户端 def embed_texts(texts): resp = client.embeddings.create(model="text-embedding-3-small", input=texts) return [item.embedding for item in resp.data] def cosine_sim(a, b): dot = sum(x * y for x, y in zip(a, b)) na = sum(x * x for x in a) ** 0.5 nb = sum(y * y for y in b) ** 0.5 return dot / (na * nb)在向量化之前,建议对数值型测试数据先做脱敏。例如“输入密码 123456”和“输入密码 abcdef”本质都是“密码错误”这一等价类,但向量化之后可能被认为是完全不同的内容。把具体取值替换成它的数据属性,比如错误密码统一替换为“WRONG_PASS”,合法手机号统一替换为“VALID_PHONE”,这样向量空间里比较的才是业务语义,而不是具体字符。
3.3 结构层判重:把用例拆成“对象-动作-数据-期望”再比较
我一度觉得 Embedding 是终极大法,直到我碰到一种很刁钻的情况。有一个输入框的规则是“6到20位字母数字组合”,AI 生成了这几条用例:
- 用例 1:输入 5 位字符,提示长度不足
- 用例 2:输入 6 位字符,通过
- 用例 3:输入 20 位字符,通过
- 用例 4:输入 21 位字符,提示长度超出
从语义相似度的角度看,这四条都是围绕“密码长度”操作,向量可能非常接近。但作为测试用例,它们是完全不同的——分别是边界下、下边界、上边界、边界上的一组经典边界值分析。如果只按向量相似度直接合并,就会把正确且必要的测试设计误杀。
所以我把这套判重流程设计成树状结构:先用结构解析做一次规则判断,再利用向量相似度做第二层召回,最后再对应保留策略。结构层做的事情是:解析出每条用例中的“操作对象”“动作”“测试数据属性”“预期结果类型”,然后把这些结构拼成一个专门用于相似度计算的短文本。关键一步是数据要做边界标记。上面的四个长度用例会被解析成如下形式:
[登录密码输入框][输入][长度为5_低于下边界][报错_长度不足] [登录密码输入框][输入][长度为6_等于下边界][通过] [登录密码输入框][输入][长度为20_等于上边界][通过] [登录密码输入框][输入][长度为21_高于上边界][报错_长度超出]当结构层发现两条用例的“对象、动作”完全一致,但“数据边界点”不同,就绝不能判重复。这一步弥补了 Embedding 看不到边界语义的缺陷。
4. 工程落地:生成、判重、复审的完整闭环
4.1 生成前的“历史用例召回”
只靠生成后判重只是补救,真正的工程化做法是把去重前置到提示词拼接阶段。我在项目里的做法是:为每个功能模块维护一个历史用例库,在生成新用例之前,先从库里召回与该需求最相似的 N 条已有用例,把它们的等价类ID和结果类型拼接进系统提示词。
具体实现上用向量检索成本最低。提前把历史用例按模块做 embedding 索引,当新需求进来时,用需求描述查询相似历史场景,找到当前模块已覆盖的等价类 ID。提示词里可以这样写:
以下是该模块历史用例中已经覆盖的等价类,请在本次生成时避免重复生成相同等价类的用例: - AUTH-LOGIN-EMPTY-USER - AUTH-LOGIN-WRONG-PASS - AUTH-LOGIN-LOCKED这个方法的本质是让模型拿到“外部记忆”。上下文窗口允许的情况下,效果比给模型装上再强的能力都明显。它解决的不只是单次生成内部的重复问题,还顺带解决了多次迭代、多个需求版本之间的重复问题。
4.2 生成后的三级判重通道
生成后我设计了三道判重关卡,每道关卡只做自己能做好的事:
第一关是规则精确查重。对新建用例做字段级归一化,然后和当前批次里所有用例比对,如果操作步骤的归一化字符串完全一致,直接标记为重复。这一步实现简单、零成本,能拦截大概 20% 的同义表达重复。
第二关是等价类 ID 查重。如果两条用例的等价类 ID 相同,并且核心操作对象相同,进入候选重复集合。这里不是直接删除,因为边界值分析场景下同一个等价类可能同时包含“等于下边界”和“等于上边界”两条用例,它们共用 ID 但不能删除。
第三关才是向量相似度判重,但只处理没有被打上等价类标签的用例。可疑相似度高于阈值的,送入人工复核队列,让测试人员决定是否冗余。
整体流程串起来大概是:规则过滤掉非常明显的赘余,ID 过滤掉业务等价类的重复,向量只负责兜底那些模型在语义空间里偷偷制造的“换皮”用例。
4.3 和现有测试管理流程接起来的落地姿势
很多团队担心接入 AI 去重引擎会打乱现有测试管理流程。其实这套判重机制完全可以做成一个旁路服务,不动原来的用例存储结构。我们项目里就是把它封装成一个 Python FastAPI 服务,对外只暴露两个接口:一个是“提交需求文本生成用例”,另一个是“对已有用例集去重”。
输出方面可以接多种格式。团队如果还在用 Excel 管用例,就输出一个结构化的 CSV 或 XMind;如果已经在用 TestRail、PingCode 这类平台,就调用它们提供的批量导入接口。我通常建议在输出时把“等价类ID”放在独立列,这样测试人员审查时一眼就能看出哪些用例职责重复。哪怕是人工维护的历史用例,也可以定期跑一遍去重程序,找出库里的存量冗余。
4.4 效果复盘:一次真实需求的数据变化
最后晒一组数据让大家有一个体感。我在一个“用户中心-安全设置”的需求上做过一次前后对比,功能点涵盖修改密码、绑定手机号、开启两步验证。使用无约束提示词直接生成第一批用例,模型共输出 158 条。人工评审后,被认为存在明显重复或覆盖不到新分支的用例有 66 条,重复率约 41.7%,有效新用例只有 92 条。
加入提示词约束、等价类 ID 标注和三级判重流程后,同一需求再次生成 180 条。规则精确查重拦截了 11 条,等价类 ID 查重发现了 23 对疑似重复,人工复核后确认其中 15 对确实冗余,向量相似度又额外召回 4 条高度相似却没有标注 ID 的用例。最终进入用例库的是 146 条,人工再筛出的重复只有 5 条左右,有效率达到 79.4%,比第一版提高了差不多一倍。
当然这不是说模型变聪明了,而是流程上把冗余卡住了。一旦把去重责任从模型身上转移到流程身上,效果就稳定了。
5. 避坑指南:调阈值、模型选择与中文环境下容易翻车的细节
5.1 阈值不是拍脑袋定的,要用标注集校准
很多人做语义相似度去重,最纠结的是阈值该设成多少。我一开始也一样,先取 0.9,发现有两条明显重复的用例相似度只有 0.88,于是调到 0.85。结果误杀了 3 条操作对象完全不同的用例。后来我明白了:阈值和测试数据强相关,没有一个万能值。
正确做法是准备一个标注集。拿历史项目里已经由人工评审过的用例对,把其中被人工判定为“重复”的标为 1,判定为“不重复”的标为 0,然后遍历不同阈值计算精确率和召回率,找到 F1 最高的点。这个标注集不用太大,一两百对用例就能让阈值稳定下来。
另外更精细的做法是按用例类型分开设阈值。功能流程类用例通常比较长,重复时相似度会非常高,阈值可以设 0.92;异常分支类用例描述本来就很接近,容易误伤,阈值设在 0.96 以上更稳妥;接口参数类用例数据占主导,文本相似度不可靠,主要靠结构层判断。
5.2 长提示词反而制造更多重复
我一开始“为了防止重复,把所有规则一股脑塞进上下文”,提示词写到三千多字。结果模型严重跑偏,开始反复使用我举例子的措辞,重复率反而更高。后来我意识到,提示词里的示例是一把双刃剑:模型会模仿示例的结构去生成,但如果示例不够典型,它会把“按示例仿写”本身当成任务。
所以示例数量控制在每组 2 到 3 个就够。更重要的是示例之间要有对比性,一个来自正常等价类,一个来自边界值类,让模型理解同一字段在不同条件下要单独成例。总提示词压缩在 1500 字以内,效果反而更好。千万不要试图把需求文档全文丢给模型再让它去重,它处理长上下文时注意力会被稀释。
5.3 中文归一化的坑:全角半角、量词、同义词
中文测试文本的归一化比英文麻烦得多。我踩过的坑包括:“用户名为空”和“用户名称为空”其实是一回事;但“账号为空”和“账号为0”完全不同。另外全角括号、中文引号、省略号这些字符如果不统一,会直接打乱 MinHash 的 n-gram 切分。
我的建议是维护一张领域同义词映射表,把需求文档和以前测试用例里出现过的同义表达都存进去。第一次跑的时候这个表很小,四十来个词,跑了一个月后它会自己长大。注意映射表不要做得太激进。把“账户”“账号”“用户名”映射成统一词根没问题,但不要把“余额不足”和“余额为零”混为一谈,后者是两条不同的业务规则。
5.4 Embedding模型也会“翻车”:给相似计算加护栏
Embedding 模型在处理领域术语时的稳定性并不像厂商宣传的那么好。就拿“锁定”这个词来说,账号锁定和按钮锁定在向量空间里距离可能非常近,但业务语义完全不同。我遇到过两条用例:“密码输入错误次数过多,账号被锁定”和“点击锁定按钮后账号被锁定”,向量相似度高达 0.94,险些被误杀。
这就是为什么我在第三层前面加了结构层护栏。结构层先比较操作对象和动作,如果对象一个指向“系统账号状态”,另一个指向“页面按钮”,那无论向量相似度多高,都绝不能判重复。向量相似度只能用于“结构已经判定为同类”的用例之间做进一步排序,不能单独作为删用例的依据。
5.5 检测到重复后,应该保留哪一条?
可能有人觉得最后一步最简单:重复就删呗。实际上保留策略很讲究。我们的经验是按这样排序保留:优先保留覆盖边界点的用例;其次保留包含明确测试数据的用例;再次保留预期结果描述更具体的用例;最后才看描述是否简洁。
比如两条用例都是“手机号格式错误”这一等价类,一条写着“输入 12345”,另一条只写“输入一个格式错误的手机号”,我们必须留前者,因为它的测试数据可以真正执行,另一条还需要测试人员自己去猜。去重不只是做减法,本质是在保证覆盖度的前提下,让剩下的每一条都有足够执行价值。
6. 关于这套方案的补充答疑
我整理几个项目协作中同事问得最多的问题,直接做成一份速查表,方便你们对照排查。
| 问题 | 原因 | 我的处理建议 |
|---|---|---|
| 相似度阈值总是不准 | 直接套用别人的阈值,没有按项目标注 | 用一两百对人工标注用例跑一遍 F1 曲线再定阈值 |
| 提示词加了去重规则后重复反而变多 | 示例太多或例子太单一,模型在“仿写示例”而不是“生成用例” | 压缩示例到 2-3 个,并拉开示例之间的差异 |
| 两条整段文字都不同的用例被判定重复 | Embedding 在比较全句,步骤和期望混在一起干扰结果 | 拆字段后分别向量化,再按权重组合 |
| 向量模型把“账号锁定”和“按钮锁定”判定为重复 | 领域术语在语义空间中存在歧义 | 先跑结构层判断操作对象,再加向量做排序 |
| 用例库越来越大,判重耗时剧增 | 每次都做全量两两比对 | 用 MinHash LSH 做候选召回,只在候选集里计算向量 |
| 人工复核队列积压严重 | 可疑用例过多且没有优先级 | 按重复概率高到低排序,抽样复核代替全量复核 |
从这套改造里我个人感受最深的一点是:去重这件事,模型的能力远远不如流程设计重要。你不需要换一个更强的模型,只需要让现有模型在生成前看到历史覆盖情况、在生成中遵循等价类标记、在生成后经过多层判重,重复率就能肉眼可见地降下来。
如果你们团队刚好也在尝试 AI 生成测试用例,我建议按照这个顺序落地:先改提示词,加重复定义和等价类 ID;再定义结构化输出;最后再考虑 Embedding 和向量检索那些看起来高级的方案。后面两个方案只在你有几千条存量用例、已经有成熟测试管理体系的阶段才值得投入。先把前面的基本功做好,你就已经解决了大部分问题。