news 2026/9/8 21:35:51

Metabase E2E 测试编写技能:从源码先行分析到 Cypress 用例生成的七阶段工作流

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Metabase E2E 测试编写技能:从源码先行分析到 Cypress 用例生成的七阶段工作流

Metabase E2E 测试编写技能:从源码先行分析到 Cypress 用例生成的七阶段工作流

【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase

本文以 Metabase 仓库中.claude/skills/e2e-test-create/下的 E2E 测试编写技能(Skill)文档为核心,完整拆解其"代码阅读优先"(Code-Reading-First)的七阶段工作流:如何从 React 组件源码提取选择器与用户流、如何启动后端与快照管理、如何按 Metabase Cypress 约定生成用例、以及 Playwright 兜底探查与清理机制。读完后你可以复现 Metabase 官方的 E2E 测试编写流程,并理解其每一步背后的 runner 实现与约束来源。

1. 技能定位:这是一个给 Agent 的 E2E 测试生成规程

e2e-test-create是 Metabase 仓库内置的一份 Claude Code 技能定义文件,位于 .claude/skills/e2e-test-create/SKILL.md。它不是一段文档式的"最佳实践建议",而是一份可执行的 Agent 操作规程,其 frontmatter 明确声明了运行边界:

  • name / description:分析 React 组件源码以理解 UI 结构,然后生成符合 Metabase 约定的 Cypress E2E 测试;只有在"读代码 + 截图调试"都不够时,才回退到 Playwright MCP 浏览器探查;
  • disable-model-invocation: true:禁止模型自动调用该技能,必须由用户显式触发;
  • allowed-tools:仅允许BashReadWriteGrepGlobSkillmcp__playwright__*,即该技能的全部动作被限定在"读代码、跑命令、写文件、浏览器探查"四类能力内。

整个规程的组织方式是一条严格的单向流水线:Phase 0 调研 → Phase 1 代码分析 → Phase 2 启动后端 → Phase 3 生成 Spec → Phase 4 验证 → Phase 5 修复(最多 2 次)→ Phase 6 Playwright 兜底 → Phase 7 清理。其中最核心的设计原则写在标题里——Code-Reading-First:在生成任何测试代码之前,必须先分析 React 组件源码来理解 DOM 结构、选择器和用户流,浏览器探查永远是最后手段。

2. Phase 0 — 调研:先读三类共享资产

在动笔写任何测试之前,技能要求先完成三项调研:

  1. 读现有 helperse2e/support/helpers/下的全部共享辅助函数(如restoresignInAsopenOrdersTable等)。Metabase 的 E2E 用例几乎不裸写cy.visit(),而是复用这些导航助手;
  2. 读表/字段 schema 常量e2e/support/cypress_sample_database中定义了示例数据库的表与字段常量(ORDERSPRODUCTS等)。仓库中该模块的实际文件是 cypress_sample_database.js(技能文档写作.ts,导入时按模块路径不带扩展名引用);
  3. 读实例数据常量e2e/support/cypress_sample_instance_data存放实例级 ID(如ORDERS_DASHBOARD_IDNORMAL_USER_ID),对应实际文件 cypress_sample_instance_data.js。这些 ID 是由快照机制生成并回填的,而非手填。

此外还要用 Glob 扫描e2e/test/scenarios/找到与被测功能最接近的现有 spec,精确模仿其模式(该目录下按 URL 结构镜像组织,如dashboarddata-studioadmin等子目录),再用 Glob 定位frontend/src/metabase/下对应功能区的 React 组件。

3. Phase 1 — 代码分析:从源码中提取选择器与用户流

这一阶段的口号是"No browser needed — source code has everything"(不需要浏览器,源码里什么都有)。具体做七件事:

  1. 定位组件:Glob + grepfrontend/src/metabase/找被测功能区的组件;
  2. 提取选择器:在相关组件中 grepdata-testid
  3. 记录可见文本:读 JSX,记下按钮标签、标题、占位符文案;
  4. 记录 aria 属性:greparia-label
  5. 理解用户流:读事件处理器(onClickonSubmitonChange)理解交互链路;
  6. 找到 API 调用:grepApi.usefetchuseQuery以及端点定义,识别出需要拦截(intercept)的 API 请求;
  7. 交叉参考现有 spec:在同功能区的现有 spec 中复用已验证的选择器与cy.intercept模式。

第 6 步与 Phase 3 直接呼应:代码分析阶段识别出的 API 调用,在生成用例时要用cy.intercept的 stub/wait 模式处理,这是 Metabase 约定中"API 时序控制"的要求(见第 5 节)。

4. Phase 2 — 启动后端:版本选择、快照生成与数据恢复

4.1 版本与启动命令

技能规定默认使用MB_EDITION=oss(开源版,无需企业版 token、更快);只有用户明确要求编写企业版测试时才用MB_EDITION=ee。启动命令为:

MB_EDITION=oss bin/e2e-backend

并要求用run_in_background: true后台运行(而非&符号)。bin/e2e-backend会自动探测后端是否已在运行并直接复用。查看 bin/e2e-backend 的实现可以看到这一逻辑:脚本先以PORT="${MB_JETTY_PORT:-4000}"取端口(默认4000),如果curl -sf http://localhost:$PORT/api/health成功,就打印"Backend already running — reusing it"并直接exit 0;否则exec node e2e/runner/start-backend.js。而 e2e/runner/start-backend.js 根据JAR_PATH环境变量决定是runFromJar(对预编译 JAR 测试)还是runFromSource(从源码启动带热重载的 live 后端),并在收到 SIGTERM/SIGINT 时调用backend.stop()清理。

4.2 快照:不要手动生成

技能明确警告:不要通过跑无关的测试 spec 来手动生成快照。原因是bun test-cypress这个 runner 默认GENERATE_SNAPSHOTS: true,会在运行任何 spec 之前自动生成快照;Phase 4 通过/e2e-test技能运行测试时,如果快照不存在会在首次运行自动补上。这一点在 runner 源码 e2e/runner/run_cypress_local.ts 中得到印证:默认选项为{ MB_EDITION: "ee", CYPRESS_GUI: true, GENERATE_SNAPSHOTS: true }(可被同名环境变量覆盖),当GENERATE_SNAPSHOTS为真时,会先rm -f e2e/support/cypress_sample_instance_data.json重置缓存,再以e2e/support/cypress-snapshots.config.js配置无头运行一次 Cypress——快照的实际生产者就是e2e/snapshot-creators/下的 spec(如 default.cy.snap.js 与qa-db.cy.snap.js)。此外 runner 还会用docker compose -f ./e2e/test/scenarios/docker-compose.yml up -d拉起测试容器,并检查前端 dev server(默认端口MB_FRONTEND_DEV_PORT8080)是否在运行,未运行则提示先执行bun run build-hot

4.3 恢复干净测试数据

启动后端后,用 testing API 恢复默认测试数据:

curl -sf -X POST http://localhost:4000/api/testing/restore/default

这条恢复接口在后续 Phase 6 重新探查前也会再次调用,保证每次浏览器探查都从相同的数据状态出发。

5. Phase 3 — 生成 Cypress Spec:Metabase 约定详解

Phase 3 的内容通过文档引用(@./../_shared/cypress-conventions.md)指向仓库中的共享约定文件 .claude/skills/_shared/cypress-conventions.md。该文件同时约束"编写"与"评审"两个场景,是 Metabase E2E 风格的权威来源,核心规则如下:

5.1 文件位置与命名

  • spec 放在e2e/test/scenarios/<area>/,目录结构镜像 URL 结构;
  • 新 spec 优先用.cy.spec.ts,现存大量.cy.spec.js仍然有效——不要在不相关工作里顺手转换旧.jsspec;
  • describe块命名模式:"area > sub-area > feature (#issue-number)"

5.2 Helpers 与常量:两条铁律

所有 helper 通过const { H } = cy;访问,绝不e2e/support/helpers直接 import。标准骨架:

const { H } = cy; import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data"; import { ORDERS, ORDERS_ID } from "e2e/support/cypress_sample_database"; describe("area > sub-area > feature (#issue-number)", () => { beforeEach(() => { H.restore(); cy.signInAsAdmin(); }); it("should do the primary happy-path thing", () => { // test }); });

另一条铁律是永远不要硬编码数字 ID——即使是测试自己创建的实体。自增主键不稳定:运行中更早的种子步骤可能把"下一个 ID"从 10 推到 11。正确做法是从创建响应里捕获并复用:

// Good — 捕获并使用 H.createDashboard({ name: "My dashboard" }).then(({ body: dashboard }) => { cy.visit(`/dashboard/${dashboard.id}`); }); // Good — alias 拦截,从响应里取 id cy.intercept("POST", "/api/dashboard").as("createDashboard"); // ...触发创建... cy.wait("@createDashboard").its("response.body.id").then((id) => { /* ... */ }); // Bad — 10 只是当时自增恰好落到的值 cy.visit("/dashboard/10");

5.3 选择器优先级与禁止清单

优先级从高到低:

  1. a11y 查询cy.findByRole()/cy.findByLabelText()(来自@testing-library/cypress)——顺带能捕获无障碍回归;
  2. cy.findByText()——元素有稳定可见文本时使用;
  3. cy.findByTestId()——对应data-testid
  4. 其他data-*属性兜底。

禁止使用:cy.get("[data-testid='...']")(必须用findByTestId)、CSS class(尤其是 styled-components/Mantine 生成的)、临时 CSS 属性选择器(path[fill="..."])、XPath。

图表测试的 ECharts 例外:e2e/support/helpers/e2e-visual-tests-helpers.js 是唯一允许用裸 CSS 属性选择器深入渲染后图表 DOM 的文件——因为 ECharts 渲染的 SVG 没有data-testid且 a11y 面很小。其中的echartsContainergoalLinepieSliceWithColorBoxPlot.*等 helper 是"有意的例外":写图表断言必须走这些 helper;遇到没覆盖的图表模式,应往该文件加新 helper,而不是在 spec 里内联cy.get("path[fill=...]")

另外两条选择器细则:位置选择器.eq(N).first()等)只在"顺序本身就是断言内容"或"紧挨着长度断言"时使用,metabase/no-unsafe-element-filteringlint 规则会对未加长度断言的.last()、负索引.eq()报警;文本选择器必须限定作用域——顶层cy.findByText(...)/cy.contains(...)会匹配整个文档,是典型的误匹配来源,应使用cy.contains("[role='dialog']", "Save")cy.findByRole("dialog").findByText("Save")within链式限定。

5.4within的三条规则

  • within必须链在既有选择器之后someSelector().within(() => {...})),裸cy.within(...)没有作用域,构造上就是错的;
  • 回调里只有一条命令时不要套within,直接链式即可——within只在两条及以上命令共享作用域时才值得;
  • 不要给within回调命名参数(within(($modal) => ...)中的参数运行时永远用不到;需要 jQuery 对象时应改用.then())。

5.5 Setup、等待与时序

  • Setup 走 API 不走 UI:用cy.request()或现成 API helper 搭建前置状态,UI 只驱动真正被测的那条流程;
  • 永远不用数字cy.wait(ms):API 时序用"先定义cy.intercept()、后触发动作、再cy.wait("@alias")"的模式;DOM 就绪优先.should("be.visible")(断言已渲染且用户可见),.should("exist")只证明节点在 DOM 中,不是就绪检查;
cy.intercept("POST", "/api/dataset").as("dataset"); // ...触发动作... cy.wait("@dataset");

5.6 永不给cy.*返回值赋值

cy.*命令是异步入队,返回的是chainer而非 DOM 节点/字符串/响应。const button = cy.findByRole(...)之后button.click()是一个经典陷阱。正确姿势是.then()内使用解出的值,或.as("alias")+cy.get("@alias")在测试后段引用。如果要给查询"起名字"方便读,用函数而不是 const

// Bad — 一次性 chainer,之后使用不会重新查询、没有重试 const foo = cy.findByText("Foo"); foo.click(); // Good — 每次调用都入队一条全新查询,带完整重试语义 const foo = () => cy.findByText("Foo"); foo().click();

cypress/no-assigning-return-valueslint 规则在 e2e 配置中已按 error 级别启用,但 helper 返回值、解构、包装对象等间接形式仍需人工审查。

5.7 断言与隔离

  • 只断言用户可见状态(文本、URL、aria 属性),不断言 DOM 结构;expect()只出现在cy.then()/cy.wrap()回调里;
  • 负断言必须配对正断言:单独的should("not.exist")在 UI 还没渲染时就会"碰巧通过"。先断言页面处于预期状态(某段文本可见、URL 正确、API 已 settle),再断言不该出现的东西不存在;
  • 对同一父容器的多个文本检查,合并成一条断言链.should("contain", "Foo").and("contain", "Bar").and("not.contain", "Baz")),一次查询、一份重试预算、原子执行;
  • 每个it()必须可独立运行,不依赖前一个it()的状态;状态重置用beforeEach()而不是before();当H.restore()H.resetTestTable()同时出现时,H.restore()必须在前(由metabase/no-unordered-test-helpers规则强制)。

5.8 用cy.log()标注步骤,而不是注释

cy.log("...")与 JS 注释在源码中同样可读,但失败时差距巨大:它出现在 Cypress 命令面板、截图和视频的时间点中,CI 失败截图能直接告诉你测试当时进行到哪一步;而//注释在运行时被剥离、在任何失败产物中都不可见。注意对等的克制:cy.log不应复述下一条自解释命令(如cy.log("Visit dashboard"); H.visitDashboard(id)是噪音),应用于阶段标记、非显然的意图、长流程的分节标题

5.9 性能三原则

  1. 不要把一条流程拆成大量小it():Cypress 单测试开销 = 框架自身的每测试装配/拆除(此代码库经验值约 5–10 秒)+ 你的beforeEachH.restore()+ 登录 + 导航,又是数秒)。拆成 8 个小测试就是 8 倍开销。先问一句:只断言"几个元素存在/可见"的测试,几乎总是错放在 E2E 里的单元测试——应移到 Jest + React Testing Library,甚至直接删除(若已有更廉价层的覆盖)。"隔离"不等于"一个断言一个测试";
  2. 扩展已有测试优先于新增近重复测试:新it()与同describe中某个测试共享 80–90% 的前置和流程、只在结尾分叉时,应扩展而非复制;
  3. 每次cy.visit()都很贵:Metabase 前端是大 Redux store 的 React 应用,冷启动要经历 store 水合、路由引导、settings/权限/用户拉取。同一测试内第二次、第三次cy.visit()是完整的应用重启而非"换页"。已在应用内时优先点击链接、面包屑、侧栏做应用内导航,保留热 store;cy.visit()仍是测试首次导航(或权限变更等确实需要整页刷新的场景之后)的正确选择。

6. Phase 4 — 验证:必须经由 /e2e-test 技能运行

生成 spec 后的验证分两步:

  1. Grepe2e/support/helpers/确认所有 import 的 helper 都存在;
  2. 必须使用/e2e-test技能运行测试,不要直接跑bun test-cypress——/e2e-test技能(见 .claude/skills/e2e-test/SKILL.md)负责版本选择、快照管理与环境变量。调用形如:
/e2e-test GREP="should do the thing" --spec e2e/test/scenarios/<path>

若创建了多个it()块,应逐个运行以隔离失败。/e2e-test技能背后的 runner 约束值得注意(均来自该技能文档):spec 路径必须经--spec传入,因为 runner 把参数交给 Cypress 的 CLI 解析器(cypress.cli.parseRunArguments),裸位置路径会被静默丢弃然后跑整个套件;按测试名过滤用GREP环境变量而非--env grep=(后者在逗号处会断);MB_EDITION=ee时需要在 shell 预先导出CYPRESS_MB_ALL_FEATURES_TOKENCYPRESS_MB_PRO_SELF_HOSTED_TOKEN等 token,且只允许检查是否已设置,绝不回显 token 值;查耗时不跑测试,直接读 e2e/support/timings.json(存储每个 spec 的最近 CI 耗时,毫秒)。此外还有 flaky 检测的压测入口MB_EDITION=oss bin/e2e-stress-test --spec <path>E2E_STRESS_RUNS=N控制迭代次数(默认 5),首个失败即停并打印截图路径。

7. Phase 5 — 修复失败:先榨干 Cypress 自己的输出

测试失败时,优先从 Cypress 输出修复:读取失败截图(路径打印在输出的(Screenshots)段下)、读取控制台的错误信息与代码框、修复后回到 Phase 4 重跑。最多尝试 2 次;两次仍无法定位,才进入 Phase 6。

8. Phase 6 — Playwright 兜底:绕过 CSP、API 登录、增量观察日志

只有 Phase 5 两次失败后才进入此阶段,且后端已在运行,无需重启。步骤是:先curl -sf -X POST http://localhost:4000/api/testing/restore/default恢复干净数据,然后用browser_run_code执行一段设置代码,做两件事——剥离 CSP 响应头(Metabase 服务严格 CSP,会拦截 dev server 脚本;这一步镜像的是 Cypress 侧的chromeWebSecurity: false)和通过 API 登录POST /api/session,凭据admin@metabase.test / 12341234,把返回的 session id 写进metabase.DEVICEcookie),再导航到http://localhost:4000并等待networkidle

进入浏览器后的关键纪律是增量维护观察日志:每完成一次重要交互,立即把观察追加到/tmp/e2e-observations.md(URL、点击的按钮与可见文本/role、data-testid、触发的 API 调用、关键 UI 状态),然后再进行下一次交互。对每个页面/流程:取一次可访问性快照(browser_snapshot)、点击交互元素、填表、触发模态框、截关键状态图。探查结束后:读回观察日志、用观察到的选择器与行为修复测试、回到 Phase 4 重跑、最后rm -f /tmp/e2e-observations.md清理。

9. Phase 7 — 清理:只按端口杀,不宽泛 pkill

所有测试通过(或彻底放弃修复)之后,必须杀掉 4000 端口上的后端:

lsof -ti:4000 | xargs kill 2>/dev/null || true

技能特别强调不要用宽泛的pkill模式——机器上可能有跑在其他端口的其他 Metabase 实例;且 Phase 2 启动的后端进程不会随 Claude 会话结束而自动退出,留着它既浪费资源又会干扰后续会话,所以清理是强制项。

10. 流程级反模式清单(What NOT to do)

技能在结尾汇总了三条流程级禁令(约定级禁令见第 5 节的约定文件):

  1. 不要把 Playwright 当第一步——永远先分析源码;
  2. 不要在阶段之间杀后端——它要在整个流程中保持运行;
  3. 不要臆造选择器——只用源码中找到或浏览器中观察到的选择器。

11. 小结:为什么这个工作流是"代码阅读优先"

纵观七阶段,e2e-test-create的设计逻辑可以归纳为三层防御:选择器可信度(Phase 1 强制从源码提取data-testid/aria/可见文本,Phase 3 的选择器优先级与禁止清单,Phase 6 的"观察后才允许写选择器"规则共同杜绝臆造选择器)、状态可信度(Phase 2 的快照自动生成与restore/default数据恢复,配合"永不硬编码 ID"的约定,保证测试起点可复现)、资源与时间成本(Phase 4 经由/e2e-test统一管理与 runner 的GENERATE_SNAPSHOTS默认行为衔接,第 5.9 节的性能三原则控制冷启动开销,Phase 7 的按端口清理防止资源泄漏)。对希望在 Metabase 或类似"前端重 + 快照驱动"架构的项目里编写 E2E 测试的开发者,这套规程与 .claude/skills/_shared/cypress-conventions.md、.claude/skills/e2e-test/SKILL.md 以及 bin/e2e-backend、e2e/runner/run_cypress_local.ts 构成的完整证据链,是可以直接借鉴的工程范式。

【免费下载链接】metabaseThe easy-to-use open source Business Intelligence and Embedded Analytics tool that lets everyone work with data :bar_chart:项目地址: https://gitcode.com/GitHub_Trending/me/metabase

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 21:34:51

res-downloader 实战教程:本地代理捕获,无水印保存视频与音频

res-downloader 实战教程&#xff1a;本地代理捕获&#xff0c;无水印保存视频与音频 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-download…

作者头像 李华
网站建设 2026/9/8 21:33:24

AI Agent学习资料整理指南:从大模型基础到多Agent实战

做AI Agent学习资料整理这件事&#xff0c;听起来就是一个人人都能做的“攒收藏夹”工作&#xff0c;但真上手之后你才会发现&#xff0c;最大的坑不是找不到资料&#xff0c;而是资料太多、太杂、太碎。我把技术博客、开源项目README、视频课、论文、社区讨论和面试题翻了个遍…

作者头像 李华