首页
/ Insomnia 烟雾测试 POM 规范:Playwright Page Object Model 的设计与落地

Insomnia 烟雾测试 POM 规范:Playwright Page Object Model 的设计与落地

2026-09-05 11:10:25作者:廉彬冶Miranda

本文聚焦 Insomnia 仓库中 packages/insomnia-smoke-test 包下的 Playwright Page Object Model(POM)约定文档,系统讲解其对象分层(InsomniaApp / Page / Component)、定位器(Locator)所有权与命名规范、API 命名规则,以及测试侧的标准用法。读完之后,你将掌握如何在桌面端 E2E 测试中构建稳定、可读、选择器安全的 POM 结构,并理解 Insomnia 如何用 fixture 链、进程重启与原生对话框 mock 把这套约定支撑起来。

1. POM 目录的定位:保持测试稳定、可读、选择器安全

packages/insomnia-smoke-test/playwright/pages/ 目录承载整个 Insomnia 桌面应用(Electron)E2E 烟雾测试的页面对象层,其约定文档 POM Conventions 开宗明义:

This directory contains the Insomnia smoke-test Page Object Model (POM). The goal is to keep tests stable, readable, and selector-safe.

这个目录直接服务于 tests/smoke/(每次 CI push 在 Ubuntu 上运行的主套件)、tests/critical/(发布时运行的关键路径测试)和 tests/migration/(数据迁移测试)三套测试,结构说明见 Smoke Test README。所有测试命令都从仓库根目录执行:

npm install
npm run test:smoke:dev   # 运行全部 Smoke 测试(dev 模式,自动拉起 echo server 与 Vite dev server)

POM 的价值在于:Electron 桌面应用的 E2E 测试天然面临选择器漂移、动画竞态、原生对话框无法自动化等挑战,把"选择器知识"集中收敛到 POM 类中,测试文件只描述业务动作,是应对这些不稳定性的第一道防线。

2. 对象分层:根门面、Page 与 Component

约定文档的第一节定义了三层对象:

  • InsomniaApp:根门面(root facade),负责装配(wires)各个 page object 与共享组件;
  • Page 对象:路由级表面(route-level surface),例如项目页(project page);
  • Component 对象:可复用或有边界的 UI 区域,例如状态栏(statusbar)、标签栏(tabbar)、侧边栏(sidebar)。

根门面实现 的 JSDoc 中给出了当前 POM 的完整装配关系:

InsomniaApp (root)
├── .statusbar           -> StatusbarComponent (footer, always visible)
├── .navigationSidebar   -> NavigationSidebar (left-side tree, always visible)
├── .projectPage         -> ProjectPage
│   └── .workspaceList  -> WorkspaceListComponent
├── .workspacePage       -> WorkspacePage
└── .preferencesPage     -> PreferencesPage
    └── .dataTab         -> PreferencesDataTab

InsomniaApp 的构造函数接收 Playwright 的 PageElectronApplication,然后在 _initPageObjects() 中一次性实例化上述所有对象;pageapp 以私有字段持有、通过只读 getter 暴露,外部无法重新赋值,而门面内部的 relaunch() 可以在进程重启后替换它们。

constructor(page: Page, app: ElectronApplication) {
  this._page = page;
  this._app = app;
  this._initPageObjects();
}

值得注意的是,InsomniaApp 还承担了超出"UI 装配"的职责——两个与 Electron 进程生命周期相关的能力:

  • relaunch():复用 fixture 暂存(stash)的启动环境变量(包括 INSOMNIA_DATA_PATH)关闭并重启 Electron 进程,磁盘状态(NeDB、secret store)在重启前后保持,重启后所有 page object 重新构建;
  • launchClone(newDataPath, envOverrides):以新的数据路径(可叠加不同的 INSOMNIA_SESSION 环境变量)拉起第二个独立的 Insomnia 实例,用于多用户/多项目并存的场景。

这两个方法都依赖 playwright/test.tsapp fixture 把启动环境暂存到 ElectronApplication 实例上的 __launchEnv 字段,若未通过该 fixture 启动则 _unstash() 会直接抛错,从结构上保证了重启语义的一致性。

2.1 Page 的公共基类:BasePage

路由级 Page 并非各自为政,而是继承自 BasePage

export class BasePage {
  readonly statusbar: StatusbarComponent;
  readonly exportModal: ExportModal;

  constructor(readonly page: Page) {
    this.statusbar = new StatusbarComponent(page);
    this.exportModal = new ExportModal(page);
  }
}

从源码结构看,BasePage 把"所有路由上都会出现"的两个 UI 区域固化为共享组件:底部状态栏与全局导出弹窗。这样任何 Page 对象(项目页、工作区页、偏好设置页)自动获得这两个组件的访问能力,避免了在每个 Page 中重复 new

3. Page 与 Component 的拆分原则

约定文档第二节给出了四条拆分规则:

  1. 路由专属的工作流(route-specific workflows)放进 Page 对象;
  2. 可复用、或可独立测试的 UI 区域放进 Component
  3. 一个 Page 组合(composes)它自己的组件;
  4. InsomniaApp 组合顶层的 page/component 并暴露快捷方式;测试应优先使用 POM API,只有在 POM 尚未建模的"缺口"处才允许直接使用裸的 page.getBy...

仓库中的实际拆分与这几条规则严格对应。以 ProjectPage 为例:它声明了对应路由 /organization/:orgId/project/:projectId(文件列表视图),组合了两个"项目专属"的组件——WorkspaceListComponent(工作区文件列表)与 NavigationSidebar(项目导航侧边栏),再继承 BasePage 提供的状态栏与导出弹窗。

WorkspaceListComponent 是典型的有边界区域组件:它只负责"文件网格中某个工作区卡片"这一局部表面,暴露 openWorkspace(name)openWorkspaceCardDropdown(workspaceName) 两个动作:

export class WorkspaceListComponent {
  constructor(readonly page: Page) {}

  get root(): Locator {
    return this.page.getByTestId('workspace-grid');
  }

  workspaceLocator(name: string): Locator {
    return this.root.getByLabel(name);
  }

  /** Open a workspace by clicking its name. */
  async openWorkspace(name: string): Promise<void> {
    await this.workspaceLocator(name).click();
  }
}

再看 StatusbarComponent,它是"全应用常驻区域"组件:

export class StatusbarComponent {
  constructor(readonly page: Page) {}

  get root(): Locator {
    return this.page.getByTestId('statusbar');
  }

  /** Open Insomnia Preferences via the statusbar preferences button. */
  async openPreferences() {
    await this.root.getByTestId('settings-button').click();
  }

  /** Open Insomnia Preferences via keyboard shortcut. */
  async openPreferencesViaShortcut() {
    const modifier = process.platform === 'darwin' ? 'Meta+,' : 'Control+,';
    await this.page.press('body', modifier);
  }
}

这个类很好地示范了"同一行为、多种触发方式"都应建模在 POM 中:openPreferences() 走 UI 点击,openPreferencesViaShortcut() 走键盘快捷键(macOS 为 Meta+,,其他平台为 Control+,),两者在测试里都是可替换的稳定入口。

实际测试中对 POM API 的使用方式(摘自 app.test.ts):

await insomnia.projectPage.importFixture('smoke-test-collection.yaml');
await insomnia.navigationSidebar.openWorkspaceActionsDropdown('Smoke tests');

4. 定位器所有权与命名规范

这是约定文档中约束最严格的一节,逐条翻译并结合源码印证:

规则 含义 源码例证
每个 page/component 必须有 root getter 定位的"锚点" WorkspaceListComponent.root 返回 getByTestId('workspace-grid')
POM 必须自行管理其动作用到的全部定位器 选择器不散落在测试里 StatusbarComponent.openPreferences() 内部的 settings-button 只在类内出现
需要参数的定位器用 xxxLocator(...) 方法命名 例如按名称取列表行 WorkspaceListComponent.workspaceLocator(name)NavigationSidebar.projectRow(projectName)
单一固定元素直接暴露为 getter 例如 rootplusButton NavigationSidebar.filterInput 返回 getByLabel('Projects filter')
定位器尽量从 root 出发做作用域限定 减少同名元素碰撞 this.root.getByTestId('sidebar-tab-projects')
属于既有 POM 的定位器,测试不得重复声明 单一事实来源 测试文件中只见 insomnia.navigationSidebar.clickRequestOrFolder(...),不见裸选择器

NavigationSidebar(左侧项目导航树,rootgetByTestId('global-navigation-sidebar'))是第 3 条规则的完整示范。它的参数化定位器按命名空间分层:

projectRow(projectName: string): Locator {
  return this.navigationTree.getByTestId(`project-node-${projectName}`);
}

workspaceRow(workspaceName: string): Locator {
  return this.root.getByTestId(`workspace-node-${workspaceName}`);
}

requestRow(requestOrGroupName: string, workspaceName?: string): Locator {
  if (workspaceName) {
    // If workspaceName is provided, scope the locator to that workspace's subtree
    // to avoid collisions between workspaces
    return this.root
      .getByTestId(`request-node-${requestOrGroupName}`)
      .and(this.page.locator(`[data-workspace="${workspaceName}"]`));
  }
  return this.root.getByTestId(`request-node-${requestOrGroupName}`);
}

其中 requestRow 额外演示了"作用域限定防碰撞"这条规则:当请求名在多个 workspace 中可能重名时,通过 .and() 叠加 [data-workspace="..."] 属性选择器把定位器收缩到指定工作区的子树内。

值得强调的是,POM 的选择器策略以 Playwright 推荐的基于角色/标签/测试 ID 的定位为主,而非脆弱的 CSS 路径:

  • getByTestId(...)statusbarworkspace-gridproject-node-${name}request-node-${name}data-testid,属于测试与应用 UI 之间的"契约";
  • getByRole('button', { name: ... }) / getByLabel(...):如 getByRole('menuitemradio', { name: actionName }) 选取下拉菜单项,getByLabel('Workspace actions menu button') 定位卡片操作按钮;
  • 仅对少数稳定 DOM 锚点使用 CSS:如 ProjectPage.root 返回 this.page.locator('.app')

5. API 命名规范:动词开头、行为单一、断言留在测试

约定文档第四节的三条 API 规则:

  1. 动作方法用动词开头命名:closeTabopenAddTabMenu
  2. POM 暴露动作与定位器,断言(assertions)留在测试文件中
  3. 方法聚焦于单一行为。

从源码看,这套"动作进 POM、断言出 POM"的原则基本被遵守,且 POM 中的"断言"仅限于两类:

  • 软等待校验:如 NavigationSidebar.clickRequestOrFolder 末尾的 await expect.soft(row).toHaveAttribute('data-selected', 'true')——这是对"点击确实生效"的即时确认,帮助尽早暴露交互失败,而不是把业务级断言写死在 POM 里;
  • 状态探测方法:如 isWorkspaceFocused(workspaceName) 返回 boolean,把判断交给调用方。

业务结论性断言(例如"删除后列表应只剩 N 项")都出现在 tests/ 目录的测试文件中,POM 保持无预期(expectation-free)。

6. 测试用法模式:每个测试一个门面实例

约定文档第五节给出的标准用法是"每个测试实例化一次,使用组合对象":

test('example test', async ({ insomnia }) => {
  await insomnia.projectPage.createCollection();
  await insomnia.tabbar.closeTab('New Request');
  await expect.soft(insomnia.tabbar.tabLocator('foo')).toHaveAttribute('data-selected', 'true');
});

需要注意:示例中的 tabbar 是约定文档给出的命名范式示意;对照当前 insomnia-app.ts 的实际装配,InsomniaApp 暴露的组合对象为 statusbarexportModalnavigationSidebarprojectPageworkspacePagepreferencesPage。其 JSDoc 中的真实示例是:

test('example test', async ({ insomnia }) => {
  // Project operations
  await insomnia.projectPage.importFixture('simple.yaml');

  // Shared components (statusbar is always present)
  await insomnia.statusbar.openPreferences();

  // Preferences and export
  await insomnia.preferencesPage.dataTab.exportProjectData('My Project');
});

insomnia fixture 本身的定义非常薄,位于 test.ts

insomnia: async ({ app, page }, use) => {
  const insomnia = new InsomniaApp(page, app);
  await use(insomnia);
}

真正的工作在 fixture 依赖链中完成:

  1. app fixture:调用 launchInsomnia() 启动 Electron,注入 INSOMNIA_DATA_PATH(每测试独立的随机数据目录)、INSOMNIA_API_URL(本地 echo server,http://localhost:4010)等一整套环境变量;启动时额外设置 PLAYWRIGHT: 'true',应用主进程据此启用测试钩子(例如消费 queueOpenDialogResponse 排队的假 showOpenDialog 响应,用来 mock 原生文件/目录选择框);
  2. dataPath fixture:生成随机数据路径,并预置 sidebarFocusForCollections: false 设置——注释解释了原因:该设置对真实用户默认开启,但会把侧边栏收缩到单个集合,破坏大量假定完整树可见的既有测试;
  3. page fixture:取 firstWindow()(插件窗口在主窗口 did-finish-load 之后才创建,因此 firstWindow 恒为主窗口),并挂接 pageerror / console.error 监听器,把渲染进程的报错显式打进测试输出;
  4. userConfig fixture:提供跳过引导、测试会话、公钥/私钥等账号态注入。

app fixture 的 teardown 也体现了 POM 生态对"进程卫生"的重视:relaunch()launchClone() 产生的额外实例统一登记在 liveApps 集合中,teardown 时先尝试优雅 close()(带 5 秒超时),失败则用 killProcessTree() 递归杀进程树——注释指出,单纯 process.kill() 只能杀掉顶层 Electron 进程,其 GPU/utility/renderer 辅助进程会被重新挂到父进程下继续持有 stdio 管道,导致 worker 收不到 EOF 而在所有测试已通过的情况下仍挂死超时。

7. 实战印证:ProjectPage 如何把约定变成"稳定"

ProjectPage 是观察这些约定落地价值的最佳样本。它的路由专属动作(createProjectcreateCollectionimportFixtureexportWorkspaceFromCard 等)全部集中在 Page 层,而跨区域的复用动作委托给组件(this.sidebar.selectProject(name)this.exportModal.selectExportFormat(format))。

其稳定化手段有四种,全部内聚在 POM 中、对测试完全透明:

  1. 竞态容错closeProjectModalIfStuck() 处理"创建项目对话框在应用已创建项目并跳转后仍不自行关闭"的已知应用侧竞态——先以 5 秒超时探测对话框是否自行隐藏,未隐藏则点击 [data-test-id="project-modal-close-button"],并对可能出现的 "Unsaved changes" 二次确认做 catch(() => {}) 容忍,最终以 dialog.waitFor({ state: 'hidden' }) 作为唯一硬校验;
  2. 可靠点击clickReliably(locator, timeout)waitFor({ state: 'visible' })click(),避免关闭动画的 backdrop 仍拦截点击(stale-backdrop);
  3. 多路径导航navigateFromWorkspaceBreadcrumb() 最多重试 4 次,优先点面包屑、不可见时回退到侧边栏项目行,每次重试间用 waitForURL 的 5 秒窗口判定是否已到达项目仪表盘路由;
  4. 原生对话框 mockimportFixture() 不弹真实文件选择框,而是通过 app.evaluate 把 fixture 文本写入 Electron 剪贴板,走"从剪贴板导入"路径;exportWorkspaceFromCard() / cloneGitProjectIntoFolder() 则先调用 mockSaveDialogForFile / mockOpenDialogForDirectoryutils)预排队的假响应。

createGitSyncProject() 中还有一段注释值得注意:等待 "Create or update dialog" 隐藏时特意用带名称的 getByRole('dialog', { name: ... }) 而非裸的 getByRole('dialog'),因为丢弃确认框可能与它短暂共存,裸角色定位器会触发 Playwright strict-mode 的"多元素命中"违规。这正是文档第 3 节"从 root 限定作用域、减少碰撞"理念在模态框场景下的延伸。

8. 约定速查表

维度 约定 检验方式
对象分层 根门面 InsomniaApp 组合 Page/Component;Page 继承 BasePage 获得共享组件 对照 insomnia-app.tsbase-page.ts
拆分边界 路由工作流进 Page;可复用 UI 区域进 Component WorkspaceListComponentStatusbarComponent
定位器 每类必须有 root getter;参数化用 xxxLocator(...);固定元素用 getter;尽量从 root 限定作用域 WorkspaceListComponent.workspaceLocatorNavigationSidebar.requestRow
选择器 优先 getByTestId / getByRole / getByLabel,避免脆弱 CSS data-testidstatusbarworkspace-gridproject-node-*
命名 动作动词开头;一个方法一个行为 openPreferencesclickRequestOrFolderselectCreateAction
断言 业务断言留在测试文件;POM 只做交互生效的软校验 expect.soft 仅见于 POM 内,测试文件持有结论断言
实例化 每测试经 insomnia fixture 获得一次门面实例 test.tsinsomnia fixture

9. 小结

Insomnia smoke-test 的 POM 约定本质上是一套"选择器治理 + 稳定性工程"规范:分层对象让选择器知识单一来源化,root 限定作用域与 role/testid 定位让选择器对 UI 重构免疫,竞态容错与原生对话框 mock 把 Electron 桌面端特有的不稳定因素封装在 POM 内部,测试文件因此保持为纯业务叙事。对于需要在 Electron 或复杂单页应用上维护 E2E 测试的项目,这套 POM Conventions 及其配套实现(fixture 链、relaunch/launchCloneliveApps 清理)提供了可以直接对照迁移的完整范式;运行入口与调试方式(npm run test:smoke:devPWDEBUG=1、trace 回放)见 Smoke Test README

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
983
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384