系列专栏:测试工程师每日一博 · Day 13
上一篇:[Day 12 · 测试右移:可观测性、混沌工程、流量回放]
这一篇是 12 天以来第一次离开"传统测试自动化"主线,横切到 AI。
目标不是谈"AI 取代测试",而是谈测试工程师怎么把 LLM 嵌进已经存在的流水线。
一、为什么是 Day 13
前 12 天我们顺着测试金字塔(Day 1)一路爬到右移(Day 12),覆盖了 Mockito、覆盖率、Testcontainers、WireMock、性能、CI、TDD、契约、Flaky、左移、右移——这是传统轴的"全力图"。
那 AI/LLM 应该插在哪?每个环节都能插才是答案:
| 系列 | 注入 LLM 的口子 |
|---|---|
| Day 1 金字塔 | LLM 生成单元用例的边界值 |
| Day 4 Testcontainers | LLM 解释"起容器失败"的报错根因 |
| Day 6 性能 | LLM 总结 JMeter 报告 + 给出 3 个 hot spot 假设 |
| Day 7 CI | LLM 自动给 flaky 跑分类(时间依赖、并发、环境……) |
| Day 10 Flaky | LLM 直接消费日志做时间线摘要 |
| Day 11 左移 | LLM 把 Gherkin 草稿扩成 acceptance criteria |
| Day 12 右移 | LLM 读 Prometheus 异常 + 关联 trace 一句话总结 |
也就是说,LLM 不是第 13 个独立工具,而是横切每一根柱子的胶水。这就是为什么 Day 13 才写 AI——前 12 篇没攒够场景,LLM 就只能表演"写代码";而真正能放大杠杆的场景是:把 LLM 嵌进已经存在的测试流水线。
二、先撕掉三个错误认知
撕掉这三个,后面才好谈:
- “AI 会取代测试工程师”——错。被取代的不是工程师,是"重复劳动"那一份工时。工程师的高级职能(质量策略、缺陷预测、风险评估)是被放大,不是被削弱;
- “AI 写测试就是给它一段需求让它生成”——错。无上下文的生成只会得到 happy path 和模糊断言;
- “AI 生成的测试可以信”——错。LLM 会幻觉出 API,虚构 selector,编造业务字段。AI 生成的测试必须用对待第三方代码的方式 review。
绕开这三坑,AI 就是工具箱里又一把螺丝刀。
三、AI 在测试里的四块拼图
| 拼图 | 输入 | 输出 | 接管哪种劳动 |
|---|---|---|---|
| 1. 用例生成 | 业务规则 / OpenAPI / Gherkin | JUnit/Pytest/Playwright 用例 | 体力 |
| 2. 断言维护 | 失败的 E2E + DOM diff | 修复后的 selector + 解释 | 维护 |
| 3. 报告解读 | CI 日志 / 性能 report / Trace | 一句话根因 + 三个候选修复 | 分析 |
| 4. 数据合成 | 字段约束 / 业务规则 | Faker + 校验一致的合成数据 | 准备 |
①②③④ 分别对应系列前 12 篇里低 ROI 但高时长的工作。下面每一块拆一篇实践。
四、实践一:用 LLM 生成单元测试(电商优惠券金额计算)
4.1 看一段被测代码
publicclassCouponCalculator{publicBigDecimalapply(BigDecimalorderAmount,Couponcoupon,LocalDateTimenow){if(coupon==null)returnorderAmount;if(coupon.getExpireAt().isBefore(now))thrownewCouponExpiredException(coupon.getId());if(coupon.getMinSpend()!=null&&orderAmount.compareTo(coupon.getMinSpend())<0)returnorderAmount;BigDecimaldiscount=coupon.getType()==CouponType.FIXED?coupon.getValue():orderAmount.multiply(coupon.getValue()).setScale(2,RoundingMode.HALF_UP);returnorderAmount.subtract(discount).max(BigDecimal.ZERO);}}4.2 让 LLM 产出"易测点"的 prompt
importopenai,json,textwrap SYS="You are a test engineer. Output strict JUnit 5 + AssertJ tests, no prose."PROMPT=textwrap.dedent(""" Generate parameterized tests for CouponCalculator.apply. Constraints: 1. Cover: null coupon, expired coupon, minSpend not met, FIXED type, PERCENTAGE type, discount greater than orderAmount (must clamp to zero). 2. Pass `now` as a parameter — do NOT use LocalDateTime.now() in test. 3. Use BigDecimal comparisons via AssertJ usingComparatorForType. 4. Do not invent APIs. If you need a Coupon factory, write a static helper inline. Code only, no markdown fence. """)resp=openai.chat.completions.create(model="gpt-4o-mini",temperature=0.2,# low temperature = more deterministicresponse_format={"type":"json_object"},messages=[{"role":"system","content":SYS},{"role":"user","content":PROMPT},],)parsed=json.loads(resp.choices[0].message.content)open("CouponCalculatorAITest.java","w").write(parsed["test_code"])4.3 这段代码做了什么
主旨是约束 prompt 而不是放任生成:
- 覆盖清单:明确列出 6 个 case,LLM 不会自己想到 “discount > orderAmount”;
- 时钟注入:不允许
LocalDateTime.now(),否则又一次掉进 Day 10 的 flaky 时间坑; - 断言规范:禁止
assertEquals比 BigDecimal(精度默认比较坑); - 不臆造 API:“如果需要 helper 自己写”——挡住 LLM 假造
CouponFactory的习惯; - temperature=0.2:测试代码要可复现,不要 creativity。
生成之后依然要人工 review,尤其是边界值和断言数值。
4.4 一个幻觉实验
把"discount 大于 orderAmount 要 clamp 到 zero"那行约束注释掉,再问 LLM。
它大概率会漏这个 case——LLM 对"满 100 减 200"这种异常优惠不敏感。
这就是为什么 prompt 工程本质上跟写"反向需求 / 边界用例"等价(Day 11 左移的支柱之一):你对边界的描述能力 = AI 生成用例的质量上限。
4.5 用 Ground Truth 做 CI 校验
生成的用例不能直接合入主干,得有"反向闸门":每个生成用例必须配一份 ground truth 期望值,CI 跑一遍比对。
importsubprocess,json,pathlibdefgrade_generated_tests(java_path:str,expectations:list[dict])->dict:""" expectations: [{"case": "expired", "expected_throw": "CouponExpiredException"}, {"case": "minNotMet", "expected_amount": "100.00"}, ...] """subprocess.run(["mvn","test",f"-Dtest={pathlib.Path(java_path).stem}"],check=True,capture_output=True)report=json.load(open("target/surefire-reports/summary.json"))passed=sum(1foreinexpectationsife["case"]inreport["passed_cases"])return{"pass_rate":passed/len(expectations),"ai_only_passed":set(report["passed_cases"])-{e["case"]foreinexpectations},"expected_but_failed":{e["case"]foreinexpectations}-set(report["passed_cases"]),}# 在 CI 加一步: grade_generated_tests(...)# 若 ai_only_passed > 0 → LLM 多写了几个用例, review 看是 hallucination 还是真补 case# 若 expected_but_failed → LLM 漏 / 错写, 必 fix这块把"AI 生成的测试"从黑盒变成白盒:它究竟多产了什么、漏了什么,一眼清楚。回归到 Day 9 契约测试的思路:期望值是契约,LLM 输出是 consumer,契约不通过 → 红灯。
五、实践二:UI selector 自愈
E2E 测试最痛的是 selector 漂移——产品改一次 CSS class,几百条用例全红。
5.1 老办法 vs 新办法
// 老办法(脆)constcheckoutBtn=page.locator('button.submit-btn-blue.v2');// 新办法(多层次降级 + LLM 自愈)constcheckoutBtn=awaitSelfHealingLocator.find(page,{role:'button',accessibleName:'提交订单',fallbackTextPattern:/(提交|下单|去结算)/,onHeal:async({oldSel,newSel,html})=>{awaitnotifySlack(`[selector healed]\n old:${oldSel}\n new:${newSel}\n html:${html}`);},});SelfHealingLocator.find内部逻辑:先用 robust selector,失败时把 DOM 截图 + 老 selector 喂给一个 fast LLM(如 Claude Haiku / GPT-4o-mini),让它返回最匹配的元素;命中后写回一个"selector registry"(JSON / YAML),下次起跑就直接读这份 registry,不依赖 LLM 实时在线。
5.2 关键设计
- 降级链:
role + name → text pattern → sonic 匹配 → LLM(每层失败才走下一层); - 本地缓存:LLM 结果写入仓库,不每次重跑(节省 token 也是稳定);
- 审计:
onHeal钩子必须 Slack 通知——selector 漂移是产品意图还是 bug,人决定,LLM 不决定。
六、实践三:Flaky 根因分析(回扣 Day 10)
Day 10 我们谈 flaky 三大根因:时间依赖、并发等待、测试污染。LLM 最适合干"读一堆日志,告诉你属于哪类"。
defclassify_flaky(failure_log:str)->dict:""" 输入一段 CI flaky 失败日志,输出根因类型 + top 3 关键证据 + 修复建议 """taxonomy=[{"name":"time-dependent","evidence":["LocalDateTime.now()","Thread.sleep","scheduleAtFixedRate"]},{"name":"resource-race","evidence":["InterruptedException","OutOfMemoryError","Connection refused"]},{"name":"data-pollution","evidence":["duplicate key","ConstraintViolation","CacheLoader returned null"]},]resp=llm.chat(messages=[{"role":"system","content":"Classify ONE flaky test root cause. Output JSON only."},{"role":"user","content":f"Taxonomy hints:{json.dumps(taxonomy)}\n\nLog:\n{failure_log}"},],temperature=0,response_format={"type":"json_object"},)returnjson.loads(resp.choices[0].message.content)# 接进 Surefire -> 失败时调一次,把 summary 落到 CI artifact把分类结果自动归并到周报里:“本周 flaky 23 次,根因分布:时间依赖 15 / 资源竞争 6 / 数据污染 2”——这就是把 LLM 当"数据加工器"用,人不读日志,LLM 读日志。
七、实践四:合成数据(回扣契约测试 Day 9 和左移 Day 11)
测试用真实数据三种死法:PII 泄露、合规违规、统计偏差。LLM 加 Faker 是黄金组合。
fromfakerimportFakerfromrandomimportchoice f=Faker("zh_CN")defmobiles_n(count:int,province:str="广东")->list[str]:""" 合规合成手机号 — 不取真实库,但符合三大运营商前缀规则 """prefixes_by_province={"广东":["138","139","135"],"北京":["136","137"],# ...其它省}return[f.numerify(text=choice(prefixes_by_province[province])+"########")for_inrange(count)]# 进一步:给 LLM 业务规则 + 一个 schema,生成符合业务规则的批量测试用户schema={"手机号":"广东移动号","余额":"(50, 999)","等级":"V1+V2+V3","异常标记":"0 或 1",}users=llm_synthesize(schema,n=500,lang="zh")这块特别契合Day 11 Gherkin acceptance criteria:Gherkin 里的 “Given 一个已登录的 V2 用户”——直接喂给 LLM,回去出 5 个等效的合成数据样例,把抽象场景变成可执行数据。
八、提示工程就是新单元测试
LLM 这种工具:提示词 = 单元测试中的"测量装置"——它怎么稳定,输出才能稳定。
8.1 三条不变量
- 可复现:同样输入两次结果一致(
temperature=0或加固定 seed); - 可断言:输出用
response_format=json_object,断言走 schema 而不是字符串 include; - 可重放:每个 prompt 永远配一段黄金样本(golden example)——类似单元测试的 fixture。
8.2 用 fast LLM 跑 eval
deftest_prompt_no_hallucinated_assertions(eval_set=50):""" 对一个生成 JUnit 用例的 prompt 跑 50 个样本, 断言"不能编造"字段出现次数 == 0 """bad=0forcaseinload("eval/calc_eval.json"):output=generate(case)if"factory.createCoupon"notincase["source"]\and"factory.createCoupon"inoutput:bad+=1assertbad==0,f"hallucination rate too high:{bad}/{eval_set}"这就是把 prompt 当一等公民来测——曲线回到测试金字塔底座(Day 1)的思路:即便 prompt 不是代码,它也该有它自己的"单元测试"。
8.3 prompt 也要进版本库
实操层面:在仓库里建prompts/目录,每个 prompt 一份 YAML,带着元数据。
# prompts/coupon_test_gen.v3.yamlid:coupon_test_genversion:3model:gpt-4o-minitemperature:0.2status:stable# draft | staging | stable | deprecatedgolden_examples:-case_id:calc_eval_001expect_contains:"CouponExpiredException"-case_id:calc_eval_002expect_not_contains:"factory.createCoupon"metrics:hallucination_rate:0.02pass_rate:0.94changelog:-v3:加约束 "do not invent APIs"(v2 时 5% 输出含臆造 factory)-v2:改用 temperature 0.2(v1 时复现率仅 60%)CI 启动时遍历prompts/*.yaml,对每个status: stable的跑一遍 golden eval。任一指标跌出阈值,PR 红灯,逼着改 prompt 的人写明新增 case 怎么修。
九、数据安全:三道闸
把 AI 接进测试流水线,最大的隐忧不是"AI 写错代码",是测试数据进了第三方 LLM。
- 不送:把 PII 字段在 prompt 之前充脱敏(
139****1234); - 本地:敏感业务用本地开源模型(Qwen2.5-Coder、CodeT5+、DeepSeek-Coder),离线推理;
- 审计:prompt 日志单独成仓,不允许明文 secret 进 prompt。
合成数据(Day 13 第七节)是天然的解决方案——你不该往 LLM 里塞生产 dump。
9.1 一个 prompt 脱敏中间件
把"脱敏"做成网关中间件,业务代码无感知:
importre,hashlib PII_RULES=[(re.compile(r"1[3-9]\d{9}"),lambdam:m.group()[:3]+"****"+m.group()[-4:]),(re.compile(r"\b\d{15,18}[X0-9]\b"),lambdam:hashlib.md5(m.group().encode()).hexdigest()[:8]),(re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),lambdam:m.group().split("@")[0][:2]+"***@"+m.group().split("@")[1]),]defredact(text:str)->str:forpat,replinPII_RULES:text=pat.sub(repl,text)returntextdefsafe_llm_call(prompt:str,**kw):"""所有接 LLM 的入口必须走这一层,否则 CI 拦"""# 1) 脱敏cleaned=redact(prompt)# 2) 拦截forsecret_patternin["AKIA[0-9A-Z]{16}","sk-[A-Za-z0-9]{20,}","-----BEGIN PRIVATE KEY-----"]:ifre.search(secret_pattern,cleaned):raiseRuntimeError(f"SECRET LEAK IN PROMPT:{secret_pattern[:8]}...")# 3) 记审计audit_log(cleaned)# 4) 真实调用returnraw_llm_call(cleaned,**kw)这个safe_llm_call是测试团队的"上层水管":任何人调 LLM 都必须经过它,然后 CI 红线卡一道——一旦发现绕过safe_llm_call直接调openai.chat,PR 拒掉。这就是用工程方式执行"数据不进 LLM"政策,而不是靠自觉。
十、AI 工具对位表(避开"取代"叙事)
| 类型 | 工具示例 | 互补角色 |
|---|---|---|
| IDE 助手 | Copilot、Cursor、JetBrains AI Assistant | 写测试样板代码 |
| E2E 自愈 | Mabl、Testim、Healenium | selector 漂移修复 |
| 报告解读 | 自家网关接 LLM | 性能 / flaky 日志总结 |
| 用例生成 | Diffblue Cover(JVM 单测) | 单测覆盖补充 |
| 数据合成 | Tonic.ai、Gretel、Mostly AI | PII 合规替换 |
注意没有一个工具是"端到端替代人"。把它们当外骨骼,不当替代品。
10.1 一个 ROI 排序小工具
测试团队上 AI 工具最容易踩的坑是"花钱买了个寂寞"。可以用一个小矩阵算 ROI:
defai_tool_roi(hours_saved_per_week:float,hourly_cost:float,tool_monthly:float,hallucination_rate:float)->dict:weekly_save=hours_saved_per_week*hourly_cost"""幻觉返工成本 = AI 输出错的部分由人工补救"""weekly_rework=hours_saved_per_week*hallucination_rate*hourly_cost*2weekly_cost=tool_monthly/4net=weekly_save-weekly_rework-weekly_costreturn{"weekly_save_usd":round(weekly_save,2),"weekly_rework_usd":round(weekly_rework,2),"weekly_cost_usd":round(weekly_cost,2),"net_weekly_usd":round(net,2),"payback_weeks":round(tool_monthly/max(net,0.01),1)ifnet>0elseNone,}# 例子:一个 selector 自愈工具每月 300 美元,声称一周省 8 工时print(ai_tool_roi(hours_saved_per_week=8,hourly_cost=40,tool_monthly=300,hallucination_rate=0.1))# 净收益若为负 → 别买; 为正但 payback > 12 周 → 再聊关键在hallucination_rate这一项——以前买工具不看幻觉率,买完才发现"省 8 工时 → 返工修 2 工时"。把这一项写进 ROI 公式,选型会更冷静。
十一、反模式清单
新增几个 AI-flavor 反模式:
| 反模式 | 后果 | 干掉的办法 |
|---|---|---|
| 灌生产 dump 进 LLM | 合规事故 + token 费用爆炸 | 用合成数据 |
| 用 temperature=0.7 生成测试用例 | 跑两次结果不同,无法做 CI | 强设 0~0.2 |
| LLM 生成的测试不经 review | 假断言偷偷混进回归套件 | CI 强制:AI 标记的文件必须人在 PR approve |
| Prompt 不进版本库 | 团队成员偷偷调多次,行为漂移 | prompt 进prompts/目录,走 PR review |
| selector 自愈默默修复 | 真正的产品改动被"友好地"修没 | onHeal 必告警 + 限速 |
| 把 LLM 当 oracle | “LLM 说这测试对就是对” | LLM 永远是 suggest,断言由人 + 真 fixture 决定 |
十二、回扣系列
| 当我们说… | LLM 接到的活 |
|---|---|
| Day 1 测试金字塔底端重复劳动 | 用例生成 + Diffblue |
| Day 7 CI “哪条 flaky 抢我的 CPU” | 自动失败根因归类 |
| Day 10 Flaky 治理 | 日志一句话根因 |
| Day 11 需求评审 Three Amigos | 把 Gherkin 草稿扩成 acceptance criteria |
| Day 12 右移监控 | Prometheus 异常一句话总结 |
也就是说,AI 是横切所有柱子的一根钢筋,而不是顶替任何一根柱子。
12.1 一个真实端到端场景:左移 → CI → 右移全链路 AI 协作
把整个系列串一遍,看 AI 在每个环节的具体输出:
Day 11 左移阶段:产品同学提了"会员积分兑换商品"。半小时候后,LLM 把这条需求扩成 8 条 acceptance criteria(Gherkin),其中 3 条是逆向需求——“积分不足 / 商品已下架 / 限购超量”——这 3 条人不太愿意主动想。LLM 帮你想了。
Day 9 契约阶段:基于这 8 条,Gherkin 喂给 Pact JVM 的 consumer test 生成器,自动产生 consumer-side 的 pact 文件,provider 只需 verify。
Day 4 Testcontainers / Day 1 单元:Java 代码 commit 时,Copilot 已根据差分(diff)为新增类生成 5-10 个候选用例,Diffblue 跑一遍,coverage 落地从 0 到 60%。剩下的 40% 留给人写——人写的是边界、并发、状态机,这些 LLM 不会写。
Day 10 flaky:CI 失败的 200KB 日志不走人工眼。fast LLM 5 秒返回"根因:OrderConsumerTest#shouldAsyncNotify等待 30s 超时,top 1 证据:Awaitility timeout after 30000ms,建议:把超时上限抬到await().atMost(60, SECONDS)"。周一早上看的就是 5 句话周报,不是 200KB 日志。
Day 12 右移:Prometheus 触发 burn rate 告警。LLM 读告警 + 关联 trace + 最近 30 条类似事件,5 秒返回"与上周三 14:00 的容量问题相似度 87%,根因候选:订单服务慢 SQLSELECT * FROM order WHERE uid=?缺索引"——人去确认索引,LLM 不动手。
五段连起来读:LLM 在每个节点上是"小助手",但每个节点都有一个人在做决策。这才是把 AI 接进测试流水线的正确姿势。
十三、原则三句话
- AI 不替代你的判断力,AI 把你的判断力放大 N 倍(N ∈ [0, +∞));
- 所有 prompt 必须像单元测试一样可复现、可重放、可断言;
- 生产数据不进 LLM——零容忍,等同把生产密钥贴到开源仓库。
十四、思考题
留给晚上:
- 你团队的 flaky 失败日志现在谁在阅读?如果是"没人",说明这块 ROI 极高;
- 现在写的测试中,有多少可以用静态分析直接判定"AI 生成可信",又有多少必须人工 review?
- Day 12 右移那根柱子(SLO/可观测),LLM 介入后能缩短"发现 → 定位 → 修复"链路里的哪一段?
- 提示词一旦进版本库,你怎么 regression test “今天的 prompt 在三个月前的数据上依然是 best”?——这正是 Day 9 契约测试的思路迁移。
十五、TL;DR
- LLM 不是第 13 个独立工具,是横切 Day 1-12 的胶水;
- 四块拼图:用例生成 / selector 自愈 / 报告解读 / 合成数据;
- 提示工程 = 新单元测试(可复现 / 可断言 / 重放中带黄金样本);
- 生产数据不进 LLM = 零容忍规则;
- AI 工具是外骨骼,不是替代品。
下一篇:Day 14 ·测试数据治理:从 fixture 到 synthetic data
把今天第七节"合成数据"那块拆出来单独讲深——为什么 fixture、fuzz、synthetic 三层要分开用,以及各自的边界在哪。下篇见 👋