我接手了一个已有 38 个测试文件、约 165 个测试用例、将近 14000 行测试基础设施的 Playwright E2E 测试套件。第一步很简单:本地跑一下。

130 个非 skipped 的测试中,只有 8 个通过了。6% 的通过率。

奇怪的是,CI 却是绿的。

背景:那个让人困惑的数字

如果你在团队里负责测试基建,遇到下面这个场景大概率不会陌生:

  • 本地跑测试,大量失败
  • CI 上跑,同样配置,全绿
  • 调查一圈发现:CI 用的是 --workers 1,本地用的是默认多 workers 模式

这听起来是个小问题,但它的连锁反应远超预期。因为多 workers 并行执行,环境隔离不当的问题会被放大——测试 A 的数据污染了测试 B,两个测试同时跑时失败,单跑时通过。在单 worker 模式下,这种问题不会出现,所以 CI 一直是绿的。

这不是某个人的错误,这是测试架构的债务。

重建认知:18 份分析文档的作用

我没有这个代码库的领域知识,不知道为什么测试要这样写、那些自定义 wrapper 在干什么、真正的问题出在哪里。

我的做法是:向 AI 大量提问。

不是"帮我修这个测试",而是:
- "这个方法的实现逻辑是什么?"
- "这个测试为什么是 flaky 的?"
- "根据 Playwright 官方文档,我们可以这样做 X,你觉得这个建议合理吗?"

连续几天下来,产生了 18 份分析文档,涵盖:架构分析、根本原因、Anti-patterns、Silent bugs、测试隔离问题。

这个阶段的核心价值是建立地图。在你对一个未知代码库一无所知的时候,每份文档都是一个被回答的问题。这比直接动手修要高效得多——因为你修的东西,你得先理解它。

Tracer Bullets:用最小切片验证架构

有了分析结果,我知道该修什么了。但另一个问题随之而来:按什么顺序改?怎么避免一次性大重构把一切搞崩?

答案来自《The Pragmatic Programmer》里的一个概念:Tracer Bullets

核心思想很简单:先构建一个穿过所有层的最小端到端切片,证明架构是可行的,然后从那里扩展。

我设定了 8 个 tracer bullets,每个针对一个特定切片:

  1. UI fixture chain — 证明 worker-scoped 和 test-scoped fixtures 可用
  2. API fixture chain — 同样的模式应用到 API 测试场景
  3. Expand UI migrations — 将验证过的 UI 模式推广到更多文件
  4. MFE-scoped projects — 把一个 Playwright project 拆成 7 个,按 MFE 文件夹分割
  5. Teardown project — 用 Playwright 的 project dependencies 添加清理项目
  6. API fixture expansion — 可组合的 API fixtures(ownerOrg → ownerProject)
  7. UI migration at scale — 剩余的 UI spec 文件
  8. API setup project — 用正经的 setup project 替换 no-op globalSetup

关键洞察:依赖图告诉我哪些 bullet 可以并行跑。Bullets 1 和 2 是独立的,Bullet 4 也是独立的,Bullet 3 依赖 Bullet 1。这个信息在后面跑多个 AI session 时派上了用场。

构建 Skill:让 AI 按同一标准执行

有了计划,33 个任务分配到了各个 phases。接下来我需要一个能一致地处理它们的工具——每次改动的流程相同、质量标准相同、benchmark 方式相同。

所以我建了一个 skill:pw-test-improvement

它的 7 步流程是固定的:

  1. Identify — 从实现 tracker 里选一项
  2. Baseline — 改动前跑 3 次受影响的测试,记录通过率和耗时
  3. Fix — 按照嵌入的 Playwright 最佳实践应用改动
  4. Test — 改动后再跑 3 次,全部必须通过
  5. Compare — 记录 before/after benchmark
  6. Update — 标记 tracker 项为完成
  7. Commit — 仅在被要求时提交,附上结构化的 PR 描述

这个 skill 里有内置知识:Playwright 的 locator 优先级(getByRole > getByLabel > getByText > ...)、anti-patterns 清单(waitForTimeout、no-op assertions、CSS class selectors、without justification 的 forced clicks)、以及把 Actions wrapper 替换为直接 Playwright 调用的迁移模式。

核心改动:Fixtures 带来的架构升级

从重复 boilerplate 到可复用 Fixtures

最大的改动是把每个文件独立调用 getUser()createOrg()createProject() 的模式,迁移到 Playwright fixtures。

Before:5 个测试文件各跑一套 setup/teardown,15 次 API 调用。

After:worker-scoped fixtures 跨文件共享,7 次 API 调用。

53% 的 API 调用削减,而且测试间的共享逻辑变得可维护了。

这里有个关键区分:

类型 作用域 适用场景
Worker-scoped ({ scope: 'worker' }) 创建一次,共享给该 worker 内所有测试 贵的 setup,比如 org、project
Test-scoped(默认) 每个测试独立创建 测试会修改的数据

项目结构拆分

Playwright 配置从一个 project 跑全部 38 个 spec 文件,拆成 7 个 projects,每个指向自己的 MFE 文件夹:

{ 
  name: 'Applications', 
  testDir: 'apps/ui/applications/e2e', 
  dependencies: ['Setup'] 
},
{ 
  name: 'Organizations', 
  testDir: 'apps/ui/organizations/e2e', 
  dependencies: ['Setup'] 
},
// ... Projects, Subscriptions, Host, User Profile

这样你可以跑 --project=Applications 只测你需要的部分,HTML 报告按区域分组,重量级 spec 有自己的并行配置。

Serial Cascade 的问题

4 个真实的测试失败,在 serial 模式下表现为 57 个失败。

Application 测试用了 serial 模式,第一个测试失败后,该 describe block 内后续测试全部标记为"did not run"。这不是 57 个 bug,是 4 个。

修复方案:把重量级 spec 拆到一个独立 project,调高超时(beforeAll 的 30s → 60s),限制 workers 数量防止 API 过载,用 worker-scoped fixtures 共享贵的 setup。

踩坑记录

Teardown 项目导致 CI 失败

我们加了一个 teardown 项目,用 Playwright 的 project dependencies 在运行后清理测试数据。本地跑正常,CI 上炸了——cleanup 跑在一个共享环境上,干扰了其他 pipeline。回滚了。

这个教训是:本地能跑不代表生产能跑。

不是所有 setup 都该做成 fixture

我们尝试把一切改成 fixtures。看完 Playwright 文档后,毙掉了一个——worker-scoped fixtures 跨文件共享,会污染那些需要 per-file isolation + 不同 option 的 serial 测试。

Fixtures 不是银弹,有些 setup 放在 beforeAll 里更合适。

我和 AI 的协作方式

不是"告诉 AI 去修"。

这是一个协作过程:

不断提问 — "这个方法干什么的?""为什么这个测试 flaky?""你确定这是对的吗?如果是,edge case X 呢?"

用文档做基准 — 我会链接 Playwright 文档,问"这和文档里的说明一致吗?"AI 的训练数据可能过期,文档不会。

多工具交叉验证 — 我用了 Goose、Claude Code、GitHub Copilot。不同的工具catch 不同的盲点,就像和不同的队友合作一样。

显式检查置信度 — "你对这个建议的置信度是多少?为什么只有 7?怎么才能到 10?"

这个做法把 AI 的不确定性暴露出来,也迫使我们更深入地思考还有什么没考虑到。

实际跑的时候

最多跑 4 个 AI session 并行——根据依赖图,哪些 tracer bullets 是独立的,就放在一起跑。

我在各个 session 之间切换,检查进度、读变更内容、在需要验证的地方介入。AI 做机械性的工作——应用模式、跑测试、capture benchmarks。我做监督——决定下一步修什么、catch 看起来不对的建议、用实际 Playwright 文档验证。

永远不超过 4 个并行。 我需要读懂正在发生的一切。

成果指标

指标 Before After 变化
单文件 API 调用次数 15 7 53% 削减
UI 测试 setup 行数 8 3 62% 削减
API setup/cleanup 行数 15 3 80% 削减
手动 try/finally 文件数 15 0 Fixtures 接管
移除的 boilerplate 约 1000 行

经验总结

关于测试:
- CI 绿不代表测试能本地跑
- 一个真实失败在 serial 模式下可以级联成几十个 phantom failures
- Web-first assertions(expect(locator))catch 到 manual checks 会漏掉的时序问题
- Fixtures 不是万能的,有些 setup 放在 beforeAll 更合适

关于和 AI 协作:
- AI 更擅长应用已知模式,不擅长发明新模式。给它一个清晰的流程。
- 分析阶段是 AI 使用最高杠杆的阶段,它找到的东西我可能花几周都发现不了。
- 多工具 > 单一工具,交叉检查 catch 幻觉、增强对方案的信心。
- Skill 让它可扩展,没有它,每个 fix 都需要重复同样的指令。
- 保持人在环里。4 个并行 session,不要无人值守。
- 把 AI 当成一个你不太了解的新同事——他不开摄像头,所以你很难完全信任他,但你知道他有好的想法和能力,你需要确认他确实想清楚了而不是在偷懒做坏决策。


附:pw-test-improvement Skill 核心代码

如果你想复用这个 pattern,下面是简化版的关键逻辑:

// skill: pw-test-improvement
// 步骤 1: Identify - 从 tracker 选择一项
const task = await identifyNextTask();

// 步骤 2: Baseline - 跑 3 次,记录结果
const baseline = await runTests({ times: 3, project: task.project });

// 步骤 3: Fix - 应用改动
await applyFix(task.fixInstructions);

// 步骤 4: Test - 再跑 3 次
const result = await runTests({ times: 3, project: task.project });

// 步骤 5: Compare & Update
if (result.passRate === 1.0) {
  await markComplete(task.id);
  await commit({ benchmark: { baseline, result } });
}

这个 skill 的核心价值是标准化:不让"差不多得了"有机会发生。


如果你也在维护一个历史包袱沉重的测试套件,希望这个案例对你有参考价值。核心不是工具,是方法论——分析、切片、验证、迭代。AI 是加速器,不是替代品。