AI 生成的 E2E 测试能抓到真 bug 吗:5 个模型 41 个用例的变异实测

2026年9月21日  AI, DevOps3 minutes

让模型写 E2E 用例,跑出来一片绿。问题在于「绿」本身不携带信息。我在一个真实的单页应用上注入了 8 个真缺陷,让 5 个模型各自生成 Playwright 用例,然后用变异检测的方式判断哪些用例真的在防回归。

结论先放这里:40 个在正确页面上能通过的用例里,5 个(12%)在整个实验里从未失败过一次;8 个缺陷里有 1 个只有 1 家抓到,其余 4 家全漏;用例数最多的模型花了 1.5 倍的输出 token,只多抓到 1 个缺陷。

环境与实验设计

  • 网关:内网 OpenAI 兼容网关 https://aihub.firstshare.cn/v1,2026-09-21 实测,temperature=0,每个模型只生成一次。
  • 运行:Playwright 1.63.0 + 系统自带的 Chromium 152。不下载 Playwright 自带浏览器,直接 launchOptions.executablePath 指向系统 chromium:
// playwright.config.js
module.exports = defineConfig({
  testDir: './specs',
  use: { headless: true, launchOptions: { executablePath: '/usr/bin/chromium' } },
});
  • 被测页面:单文件 HTML,内联 JS,无框架,5.6 KB。包含登录(空字段时按钮禁用,admin / secret 通过)、成员表(12 条数据、每页 5 条、「共 N 条」计数、点姓名表头切换排序、按姓名搜索且大小写不敏感)、添加成员(新增后清空输入框)。
  • 页面用 file:// 打开,整个实验没有任何常驻进程和监听端口。

8 个变异体,每个只改一行,都是外部可观察行为的改变:

ID改动可观察差异
m1搜索去掉 toLowerCase()ALICE 由 1 条变 0 条
m2PAGE_SIZE = 510每页行数与「第 1 / 3 页」变化
m3错误提示文案改掉断言固定文案的用例失败
m4添加成员后不清空输入框输入框 value 变化
m5排序开关失效,永远升序首次点击表头不再变降序
m6登录按钮不再按空字段禁用toBeDisabled() 失败
m7末页「下一页」不再禁用边界态变化
m8「共 N 条」改用全量数据搜索后计数不更新

变异体和基线页面由脚本从同一份 app.html 做单点字符串替换生成,每个锚点在基线中唯一,改动内容可复核。

协议:把页面源码全文放进 prompt,要求输出单个 @playwright/test 文件,覆盖登录校验、登录成功、搜索、分页、排序、添加成员。生成时告诉模型应用在 http://localhost:3000/,跑之前只做一件事——把 spec 里的这个地址替换成本地 file:// 路径,其他一字不改。

判定:只有「在正确页面上通过、在该变异体上失败」的用例才算真的抓到了缺陷。基线就失败的用例不算击杀——它只是坏的用例碰对了。

结果

模型用例断言击杀漏掉输出 token生成耗时
MiniMax-M318538/8850244.6s
claude-sonnet-4-66817/8m1787925.2s
deepseek-v4-pro5517/8m11047836.2s
deepseek-v4-flash6687/8m11102335.8s
qwen3.8-flash6(5 个基线通过)517/8m1533675.1s

m2 到 m8 这 7 个缺陷被 5 家全部击杀;唯一活下来的是 m1。5 家合计击杀 36/40。

唯一漏掉的缺陷:搜索的大小写

m1 的改动就是这一行:

// 基线
return m.name.toLowerCase().indexOf(keyword.toLowerCase()) >= 0;
// 变异体
return m.name.indexOf(keyword) >= 0;

只有 MiniMax-M3 写了这条用例,它也是整轮里唯一杀死 m1 的用例:

test('搜索应大小写不敏感', async ({ page }) => {
  await page.locator('#search').fill('ALICE');
  await expect(page.locator('#count-info')).toHaveText('共 1 条');
  await expect(page.locator('.cell-name').first()).toHaveText('alice');
});

另外 4 家都测了搜索,输入全是小写 alice。差异不在工具、不在框架、也不在覆盖率——5 家拿到的是同一份源码,toLowerCase() 就写在第 30 行。差的是模型有没有想到「用户会开着大写锁定敲搜索框,或者直接粘贴一段大写文本」。

这是 AI 生成用例最像人的地方:覆盖面取决于它的联想,而不是取决于被测代码里有什么。指望用覆盖率指标找出这种缺口是不行的,fill('ALICE') 和不写这条用例,行覆盖完全一样。

12% 的用例一个缺陷都没抓到

40 个有效用例里有 5 个在 8 个变异体上全部通过,意思是它们对「有没有被改坏」不敏感。典型例子:

test('两个字段都填写时登录按钮应启用', async ({ page }) => {
  await page.locator('#username').fill('admin');
  await page.locator('#password').fill('secret');
  await expect(page.locator('#login-btn')).toBeEnabled();
});

它断言的是一个从来没有被破坏的状态。这种用例进 CI 唯一的作用是让报告变长。注意它们的通过率是 100%,任何按「通过率」「用例数」来评价生成质量的看板都会给它满分。

基线就挂的用例:模型把数据数错了

qwen3.8-flash 有一条用例在正确页面上就失败:

await page.fill('#search', 'a');
await expect(page.locator('#count-info')).toHaveText('共 6 条');   // 实际:共 7 条

页面里 12 个名字中含 a 的是 7 个(alice、carol、dave、frank、grace、ivan、mallory),模型数成 6 个。这条用例永远不可能变绿,但它会被算进「生成成功 / 用例数 +1」的统计里。

所以生成用例的第一道门禁只能是「在正确代码上全绿」,第二道才是「对已知缺陷失败」。顺序反过来,你会把坏用例当覆盖。

用例数量与断言密度

  • MiniMax-M3 用 18 个用例、8502 输出 token 换到 8 个击杀,其中 5 个用例从未失败。
  • claude-sonnet-4-6 用 6 个用例、81 个断言换到 7 个击杀,断言密度 13.5/用例;MiniMax 是 2.9/用例。
  • deepseek-v4-flash 用了 22 次 .nth(0) / .nth(4) 定位表格行。它能杀掉 m5(排序失效)靠的是断言内容,不是定位方式——一旦行序因为功能变化而改动,这 22 处会集体变成等待超时。

「多写用例」和「写深断言」不是同一件事。这一轮里,让击杀数上升的是后者。

为什么通过率不能当质量指标

根因是 test oracle 的位置。模型只能从源码推断「预期行为」,它写下的断言就是它对这份代码的理解。代码是错的,它就照着错的写断言——文献里把这叫「验证 bug 的测试」:一项针对商用与开源生成工具的研究(arXiv 2412.14137)发现,带过滤/自动修复机制的生成工具,最终套件里最多 68.1% 的用例会在错误实现上通过、在正确实现上失败。

另外两个对照,说明这个问题的量级不是本轮实验特有:

  • ACL 2026 Findings 的 SWE-Mutation 在单元测试上测了 7 个模型,最好的 DeepSeek-V3.1 相对检出率只有 36.15%;而且语义级变异体比规则变异体难杀得多(平均检出率 71.04% → 39.81%)。
  • 本轮 E2E 的 90%(36/40)不能拿去和 36.15% 对比。这里每个变异体都改动了页面上的外部可观察行为,而 m8 那种「只改一句文案/计数」在单元测试里属于最容易杀的一档;而且模型拿到的是完整源码,是白盒。

真正可操作的部分是:用变异检测来验收这批用例。m8 只改了计数文案,覆盖率一点没变,通过率一点没变,只有变异检测能把它标出来。

落地清单

  1. 生成完用例先跑一次变异检测——手工注入 8 个一行改动就够,不需要引 Stryker 这类完整工具链。
  2. 门禁两条:基线全绿;对已知缺陷失败。两条都不满足的用例退回重写,不要合入。
  3. 断言写页面状态(计数、文案、行序、按钮禁用态),不要只写 happy path 的可点击/可见。
  4. 静态数据能数清的,让脚本生成期望值(如「共 N 条」按数据源算),不要靠模型心算——qwen 那条 共 6 条 就是这么挂的。
  5. 定位器优先 id / role / label;.nth(索引) 定位表格行的地方标记出来,排序或分页一改就是一批超时。

再用哪家的生成能力是次要问题:这一轮 5 家的差距在「有没有想到那条边界」上,而不在能不能写出可运行的 Playwright 代码——5 家的用例都能跑起来。

边界

  • 单个页面、8 个 UI 级变异体、每个模型只生成一次、每个变异体只跑一次,没有做重复与 flaky 统计。
  • 模型拿到完整源码(白盒)。只给需求文档的话,结果只会更差,但差多少没测。
  • file:// 打开页面,与真实 dev server 的差异在跨域和 fetch 上,本页没有这两类行为。
  • 8 个变异体都至少被一家击杀过,所以集合里没有等价变异体(改行为但不改观察结果的那种)混入。
  • 生成侧还有 2 家在本次调用里没跑通:glm 渠道返回 403 client_not_allowed,kimi-k3 拒绝 temperature=0(要求 temperature=1)。这两个是网关侧的接入约束,不代表模型能力。
  • m1 只被 1 家击杀,只说明这一次生成里另外 4 家没写这条用例,不代表它们写不出来。

测试与质量工具清单见 awesome-x-ops 的 Testing Tools 分类 。站内相关:AI Code Review 横评——CodeRabbit vs PR-Agent vs Copilot Review 讨论的是生成之后谁来看的问题,这篇讨论的是生成出来的东西怎么验。