为 EUI 构建可靠的 Playwright 组件测试对象:`@elastic/eui-test-helpers` 贡献指南全解析
2026/9/17 14:24:06 网站建设 项目流程

为 EUI 构建可靠的 Playwright 组件测试对象:@elastic/eui-test-helpers贡献指南全解析

【免费下载链接】euiElastic UI Framework 🙌项目地址: https://gitcode.com/GitHub_Trending/eu/eui

@elastic/eui-test-helpers是 Elastic UI(EUI)框架下的独立测试辅助包,面向 Scout / Playwright 等消费方测试,提供封装了用户级交互语义的Playwright Component Objects,让端到端测试只关注断言本身、不再与 EUI 的 DOM 细节纠缠。本文以仓库中的 CONTRIBUTING.md 为骨架,结合packages/test-helpers包的源码、配置与验证测试,完整讲解它的设计原则、目录结构、新增 Component Object 的完整流程、本地验证测试的运行方式、发布前在 Kibana 中的校验链条以及 CI 集成机制,帮助你既能熟练使用这些 helper,也能为它贡献新的组件对象。

一、库的定位:能做什么、不做什么

@elastic/eui-test-helpers的边界在 CONTRIBUTING.md 中被定义为两条清晰的原则:

  • 它负责:让消费方的端到端测试能够可靠地建立和拆除组件状态,从而把测试精力集中在真正的断言上——而不是花在"摸清 EUI 的 DOM"上。
  • 它不负责:测试 EUI 组件自身的行为。EUI 已有自己的 RTL 单元测试、Cypress E2E 测试和 Loki 视觉回归(VRT)测试体系。如果想为某个 EUI 功能专门添加公开方法(例如"点击清除按钮以验证onChange是否触发"),那应该属于 EUI 自身的测试套件。本包中的验证测试只负责确认helper 本身工作正常,不能、也不应重复 EUI 自身的测试。

从 README.md 可以看到,该包目前主要面向 Playwright / Scout 消费方,未来可能扩展 Cypress 与 React Testing Library 的 helper;README 也坦率指出库仍处于早期阶段,欢迎贡献缺失的实用工具。

安装与版本配套

由于 helper 直接面向 EUI 组件的 DOM 和data-test-subj,因此版本必须与所测试的@elastic/eui版本匹配(helper 与@elastic/eui独立发版):

yarn add --dev @elastic/eui-test-helpers

@playwright/test被声明为 peerDependency(^1.50.0,可选),由消费方在运行时自行提供版本。

使用方式速览

import { EuiComboBoxObject } from '@elastic/eui-test-helpers'; const comboBox = new EuiComboBoxObject(page, 'dataViewSelector'); await comboBox.setSelectedOptions(['logs-*']); expect(await comboBox.getSelectedOptions()).toEqual(['logs-*']);

每个 Component Object 构造函数都接收(scope, testSubj)

  • scope—— 一个 PlaywrightPageLocator,用于限定搜索范围;
  • testSubj—— 你在应用中设置在组件根元素上的data-test-subj值。

当前包内已提供 16 个组件对象(见 index.ts 的导出与 README.md 的清单):EuiComboBoxObjectEuiDataGridObjectEuiSuperSelectObjectEuiGlobalToastListObjectEuiSelectableObjectEuiDraggableObjectEuiFilterButtonObjectEuiRangeObjectEuiPopoverObjectEuiFlyoutObjectEuiAccordionObjectEuiContextMenuObjectEuiModalObjectEuiBasicTableObjectEuiColorPickerObjectEuiToolTipObject,每个组件均有独立的src/components/<name>/README.md文档。

二、目录结构:组件对象、选择器与验证测试如何组织

CONTRIBUTING.md 给出了完整的目录蓝图(相对packages/test-helpers/包根):

src/ playwright/ base_object.ts # 共享 Playwright 基类 components/ <name>/ object.ts # Component Object(Playwright) object.spec.ts # 验证测试 —— 默认配置 object.props.spec.ts # 验证测试 —— 非默认 props object.multiple_instances.spec.ts # 可选 —— 多实例作用域 components/ <name>/ selectors.ts # 框架无关的 test-subj 常量 README.md # 组件级 API 文档 storybook.ts # 框架无关的 Storybook URL 构造器 selectors.ts # 包级通用选择器 index.ts # 公共导出

仓库实际结构与之完全吻合,例如 src/components/combo_box/selectors.ts 与 src/playwright/components/combo_box/object.ts,以及 src/storybook.ts 中统一提供storyUrl(id, args?)构造/iframe.html?id=...&viewMode=story&args=...形式的 Storybook 地址。

需要注意三个路径概念的差异:

  • src/playwright/components/<name>/—— 存放 Playwright 专属的 Component Object 类与对应验证 spec;
  • src/components/<name>/—— 存放框架无关selectors.ts常量与 API 文档(未来若扩展 Cypress / RTL helper,可复用同一套选择器);
  • src/index.ts—— 包的唯一公共导出入口,新增对象必须在此 re-export。

三、设计原则:写一个"对"的 Component Object

CONTRIBUTING.md 定义了 7 条核心设计原则,每条都能在源码中找到对应实现证据。

3.1 最小公共 API

保持方法为private,直到出现真实的外部使用场景。公共方法越多,需要长期保持稳定的 API 表面就越大,调用方也就越容易耦合到 EUI 内部 DOM 细节。以 combo_box/object.ts 为例,clickPillClearButtonsclearPillWithoutCloseButtondeleteSearchInputdeselectAllFromDropdown等策略方法全部为private,对外只暴露 5 个方法。

3.2 配置无关的公共方法(智能自动检测)

公共方法必须在所有支持的 prop 配置下都能工作,且调用方无需指定自己处于哪种变体——方法内部通过探测 DOM 来检测配置并分发到正确的内部策略。调用方永远只写await comboBox.clear(),至于底层怎么清除是实现细节。

源码中clear()就是教科书式的自动检测实现:

async clear(): Promise<void> { if ((await this.getSelectedOptions()).length === 0) return; if (await this.hasPills()) { if (await this.hasPillCloseButtons()) { await this.clickPillClearButtons(); // 多选 pill 带 × 按钮 } else { await this.clearPillWithoutCloseButton(); // singleSelection 无关闭按钮,用 Backspace } return; } if (await this.hasConfirmedInputSelection()) { await this.deleteSearchInput(); // asPlainText 模式直接删输入 return; } await this.deselectAllFromDropdown(); // 兜底:从下拉中逐个取消选中 }

getSelectedOptions()同样根据"是否有 pill"与"是否有已确认的输入选择"分派三种读取路径。

3.3 选择器的单一事实来源

每个data-test-subj值和 CSS 选择器都集中在src/components/<name>/selectors.ts,禁止在 helper 或 spec 文件中内联 test-subj 字符串。查看 combo_box/selectors.ts,常量按*_SELECTOR(CSS)与*_TEST_SUBJdata-test-subj名)两类命名,注释还记录了每条选择器的语义与坑点。

3.4 读取同步的 DOM 状态,而非异步副作用

优先读取 EUI 渲染函数中同步设置的 CSS 类,而不是异步图标加载(data-icon-type)、延迟的aria-*属性或动画。只有不可避免时才使用expect.poll(),并且必须注释说明原因。例如isPlainText()通过探测.euiComboBox__inputWrap--plainText类是否存在来判断模式;而setSelectedOptions()在 asPlainText 模式下用expect.poll()轮询getSelectedOptions(),因为该模式的选择经消费方onChange提交,可能晚于点击一拍才落定——代码注释明确解释了这一取舍。

3.5 读取稳定的 class,而非可被覆盖的data-test-subj

某些组件会把消费方传入的data-test-subj展开(spread)到内部元素上、覆盖其自身默认值——例如EuiComboBox某个选项的data-test-subj会落在其渲染出的 pill 上并覆盖默认的euiComboBoxPill。若读取键基于该默认值,就会静默返回空。因此当元素总是携带稳定的 EUI class 时,应按 class 读取(.euiComboBoxPill),而不是按可被消费方覆盖的data-test-subj。selectors.ts 中PILL_SELECTOR: '.euiComboBoxPill'的注释正是此原则的直接落点:data-test-subj常量保留用于"按值定位",但枚举内部元素不依赖它。

3.6 读取时考虑虚拟化

集合类组件(combo box、data grid、selectable)会对选项做虚拟化——列表滚动时条目会挂载/卸载,完整集合永远不保证在 DOM 中。因此任何读取、匹配或枚举条目的方法都不能假设自己能看到全部:必须要求显式搜索词(或精确文本 / accessible-name 匹配),让目标先被过滤进 DOM 再断言,而不是只返回当前渲染的子集。optionFor在 combo_box/selectors.ts 中带注释提醒这一点,getAllVisibleOptions()的文档也明确"这是可见切片,不保证是全部选项"。

3.7 多实例安全的作用域定位

每个 locator 都必须限定到this.root,绝不能直接挂在page上。对 portal 元素,使用${testSubj}-optionsList模式防止跨实例串扰——EUI 会把消费方的data-test-subj${testSubj}-optionsList的形式传播到 combo box 的选项列表上,使得同页存在多个 combo 时也能精确作用域。BaseObject中的testSubj字段注释与 selectors.ts 的optionFor(testSubj)函数均体现了这一点。

3.8 键盘事件限定到元素

使用locator.press()而不是page.keyboard.press();尽量避免Escape——它会冒泡到页面级处理器(modal、flyout 的关闭监听)。优先点击切换按钮或调用locator.blur()来关闭下拉。setSelectedOptions()末尾用searchInput.blur()关闭下拉而非按 Escape,代码注释明确写着这是为了避免冒泡到消费页的 modal/flyout 关闭处理器。

3.9 可继承性准备

内部 getter 声明为protected而非private,便于子类复用而无需重复实现。EUI 自身的组件层级让这一点很关键:EuiInMemoryTable构建于EuiBasicTable之上、EuiBasicTable又构建于EuiTableEuiBetaBadge构建于EuiBadge。未来的EuiInMemoryTableObject应当能够继承EuiBasicTableObject并复用其 locator。base_object.ts 中的scoperoottestSubjcomponentSelector全部为protected

四、BaseObject 基类:组件对象的公共底座

所有组件对象都继承自 src/playwright/base_object.ts 中的BaseObject。理解它,就理解了整个包的运行机制:

  • 构造签名constructor(scope: ObjectScope, testSubj: string, componentSelector?: string),其中ObjectScope = Page | Locator | BaseObject——把另一个组件对象当作scope传入即可实现 DOM 子树内的嵌套组合。
  • test-subj 匹配语义testSubjSelector使用[data-test-subj~="..."]空格分词匹配(而非getByTestId的精确匹配),因为像EuiColorPicker这类组件会在消费方 subj 之外追加自己的 token。
  • Proxy 自动守卫:构造函数返回一个Proxy,包裹所有异步公开方法,在每次调用前自动执行assertComponent()。构造函数无法是异步的,因此用 Proxy 在调用前守卫;同步方法则原样透传以保持同步返回类型。
  • assertComponent():当设置了componentSelector(如 combo box 的.euiComboBox)时,校验testSubj命中的元素确实匹配该选择器,否则抛出"Are you using the right Component Object for this element?"的错误;命中后记忆化跳过;元素不存在时跳过(那是调用方法自己要处理的情形,比如clear()对空选择是无操作)。

五、新增一个 Component Object 的完整流程

CONTRIBUTING.md 给出 5 个步骤,配合源码可逐一对号入座:

  1. 选择器(Selectors)—— 在src/components/<name>/selectors.ts中添加data-test-subj常量,任何其他地方都不得内联 test-subj 字符串。
  2. 对象(Object)—— 在src/playwright/components/<name>/object.ts中新增继承BaseObject的类,从selectors.ts导入常量;公共表面保持最小,检测逻辑实现在公共方法内部,而不是暴露变体专属方法。
  3. 验证测试(Validation tests)—— 按下方 spec 文件结构组织。
  4. 重新导出(Re-export)—— 在 src/index.ts 中导出新类(目前 16 个对象全部在此导出)。
  5. 文档(Docs)—— 添加src/components/<name>/README.md记录公共 API 与自动检测行为(可参考 combo_box/README.md 的"Pill mode / Plain-text mode"说明与 API 表格写法)。

Spec 文件结构:按关注点拆分,而非按 story 或方法

一个文件对应一个关注点(默认行为,或一族相关的非默认配置),而不是一个 story 一个文件、一个方法一个文件:

  • object.spec.ts—— 仅默认配置;按公共方法分组到嵌套的describe块中(参考 combo_box/object.spec.ts,beforeEach中先page.goto(PLAYGROUND_URL)等待渲染、构造对象并clear(),然后按setSelectedOptions/clear等方法分组断言)。
  • object.props.spec.ts—— 所有改变 DOM 或交互模型的非默认配置;每个配置一个describe+beforeEach,可横跨多个 Storybook story。
  • object.multiple_instances.spec.ts—— 仅当多个实例可同页共存时添加。

命名规范:实例以组件命名(comboBoxdatePicker等),多实例测试追加数字(comboBox1comboBox2)。所有 spec 都必须通过 src/storybook.ts 的storyUrl()构造页面地址,严禁内联/iframe.html字符串——这正是storyUrl统一封装的目的,同时还能通过args参数注入data-test-subj等 Storybook 参数(如 combo box 验证测试中的data-test-subj:testComboBox)。

六、本地运行验证测试

验证测试跑在 EUI 的 Storybook 之上,因此需要先启动 Storybook:

yarn workspace @elastic/eui build:workspaces # 一次性:构建 eui-theme-common + eui-theme-borealis yarn workspace @elastic/eui start # 启动 Storybook,默认 http://localhost:6006

等待 Storybook 编译完成后,在仓库根目录执行:

yarn workspace @elastic/eui-test-helpers test

这条命令依次执行tsc --noEmit(类型检查)与playwright test(端到端验证)。只跑 Playwright 测试则用test-e2e(对应 package.json 中的test: "yarn lint && yarn test-e2e"test-e2e: "playwright test")。全新检出环境下先安装一次 Playwright 浏览器:

yarn workspace @elastic/eui-test-helpers exec playwright install chromium

失败后查看 HTML 报告(含 trace、截图与完整调用日志):

yarn workspace @elastic/eui-test-helpers show-report

webServer 的本地/CI 双模式

playwright.config.ts 中的webServer是本工作流的关键:

  • 本地reuseExistingServer: true(仅非 CI 时),当:6006已有 dev server 时直接复用;
  • CIreuseExistingServer关闭,webServerhttp-server静态托管 EUI 预构建产物packages/eui/storybook-static。该目录是 gitignore 的构建产物,必须先通过yarn workspace @elastic/eui build-storybook生成(test-helpers的 CI 任务会自动执行)。

配置文件还透露出与 Scout 对齐的细节:testIdAttribute: 'data-test-subj'(这是getByTestId生效的前提)、retries: 0(与 kbn-scout 默认一致)、timeout: 60_000expect.timeout: 10_000、CI 下 trace 采用retain-on-failure

七、发布前必须经过 Kibana 校验

Storybook 验证测试只能证明 helper 在隔离环境中可用,不能证明它在真实消费方(生产 DOM、父组件控制的状态、更严格的测试断言)中可用。发布前必须在 Kibana(Scout 的主要消费方)中验证,而不是发布后再补救。CONTRIBUTING.md 给出 4 步:

1. 先在 Kibana 中原型化 Component Object。在 Kibana 的kbn-scout包中开发迭代,再移植到这里:

  • 将对象放到src/platform/packages/shared/kbn-scout/src/playwright/eui_components/,注册到page.componentsfixture 上,让 spec 以page.components.<name>(testSubj)调用(combo box 是参照实现:page.components.comboBox('myComboBox'));
  • 编写/改写使用它的 Scout spec 并在本地运行。

在 Kibana 中迭代意味着你是在 helper 必须真实支持的 DOM 上练手,而不是精心挑选的 story。

2. 提交 Kibana 草稿 PR 并跑 CI。本地运行只覆盖一部分,CI 会跑所有 stateful/serverless lane,确认使用该 helper 的 spec 在所有 lane 通过。

3. 移植到 EUI 并以 snapshot 发布。验证通过后,将对象(连同selectors.ts、验证 spec 和 README,按上文"新增 Component Object"流程)移入本包并开 EUI PR。要让已发布形态的 helper 通过 Kibana CI,必须使用snapshot——Kibana CI 从 npm registry 安装依赖而非 git ref,而这个包只是 EUI monorepo 中的一个 workspace,无法让 Kibana 指向某个 EUI 分支或 commit。两条路:

  • Nightly:EUI 在工作日自动发布snapshotdist-tag 下的快照(见.github/workflows/update_kibana_dependencies.yml中的调度与 dist-tag),将 Kibana 的@elastic/eui-test-helpers锁定到该精确版本;
  • On demand:给 EUI PR 打上ci:regression-integration-test-kibana标签,从 PR head 构建快照并触发 Kibana 集成链(需要elastic/eui的 write/label 权限)。

合并 Kibana PR 前必须将依赖重新固定到官方 release——快照是可移动的预发布版本,会被清理。

4. 关注首次尝试的失败,而不只是最终状态。Scout 会对失败的 spec 重试一次,所以某个 lane 整体可能为绿,但首次尝试实际失败过。使用你 helper 的 spec 出现首次尝试失败,往往指向 helper 自身的 flakiness(被重试掩盖的竞态或时序 bug)——发布前务必检查并修复。

八、CI 集成与 flake 检测

这些验证测试运行在 EUI 的 Buildkite CI 上,每个 PR 都会执行;当组件变更时触发 flake 检测。详见仓库 wiki 的 Testing → EUI test helpers。

flake 检测按目录路径将组件与其 helper 关联:变更packages/eui/src/components/<name>下的组件、src/playwright/components/<name>下的 helper spec,或src/components/<name>下的选择器,都会重跑该 helper 的 spec。这里的<name>是相对 components 目录的路径,可以嵌套(如form/super_select),与 EUI 源码布局保持一致——因此新增 Component Object 时只要保持目录对等,就不需要额外接线。

九、总结:贡献一个组件对象的最小检查清单

综合以上内容,向@elastic/eui-test-helpers贡献新的 Component Object 时,请逐项核对:

  1. 选择器集中在src/components/<name>/selectors.ts,无内联字符串;
  2. 对象继承BaseObject,公共方法配置无关(内部自动检测),私有策略不暴露;
  3. 公共方法同步读取 DOM 状态、按稳定 class 读取、注意虚拟化、locator 限定this.root、键盘事件用locator.press()
  4. 内部 getter 用protected为子类继承留余地;
  5. 验证 spec 按关注点拆分(默认 / 非默认 props / 多实例),命名规范,统一用storyUrl()
  6. src/index.tsre-export 并补充README.md
  7. 目录路径与 EUI 源码保持对等(含嵌套路径),以复用 CI flake 检测;
  8. 发布前先在 Kibana 的 kbn-scout 中原型化并跑 CI,用 snapshot 验证,最终固定到官方 release。

遵循这套流程,既能保证 helper 在 Storybook 与真实消费方两端都可靠,也能让整个 EUI 生态的端到端测试长期稳定、可维护。

【免费下载链接】euiElastic UI Framework 🙌项目地址: https://gitcode.com/GitHub_Trending/eu/eui

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询