news 2026/10/21 19:55:47

用Playwright构建Electron应用E2E测试:配置、稳定性与CI落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Playwright构建Electron应用E2E测试:配置、稳定性与CI落地

1. 测试策略的起点:Hollama 到底需要保护什么

Hollama 是一个我参与维护的本地大语言模型管理工具,核心功能是把多个本地模型统一塞进一个干净的界面里:拉模型、配置参数、开多个会话、对比不同模型的回答。用户大多是在自己的电脑上跑模型,看重的是隐私和数据不出本机,但也正因为是本地应用,机器环境千奇百怪,Windows 上的路径、macOS 的权限、Linux 的代理设置,任何一个环节出问题都可能导致用户直接放弃。

当初团队对测试策略的讨论很直接:单元测试和组件测试覆盖了大量内部逻辑,但用户真正感知的是“从打开应用,到选中模型,到发出第一条消息,再到看到回复”这条完整链路。这条链路如果断了,任何底层测试都发现不了。端到端测试(E2E)要补的正是这个空档。

E2E 测试的价值不是“多跑几个用例”,而是把产品最核心的业务价值链用真实用户的操作方式固定下来。Hollama 的核心链路有几条:启动后能否正确加载模型列表、能否新建会话并输入提示词、能否从流式响应中拿到内容并渲染、切换模型后会话上下文是否保持、配置弹窗中的参数修改是否真正生效。这些场景没有哪个是单测能独立验证的,因为问题往往出在前端状态管理、后端进程通信、本地文件读写三者的交界处。

在选型上,我们不是没考虑过 Cypress,也很认真地看过 Selenium。Cypress 在调试体验上很好,但它的架构决定了它偏向浏览器内的自动化,对于桌面应用中常见的多窗口、文件下载、系统通知这类场景,支持起来比较吃力。Playwright 用一套统一的 API 同时支持 Chromium、Firefox 和 WebKit,而且它在 Electron 的支持上很干净,可以直接启动桌面应用。Hollama 的桌面壳是基于 Electron 的,这一点直接决定了最终选 Playwright。

另一个理由是 Playwright 的自动等待机制。写 E2E 最烦的就是 sleep(1000) 这种拍脑袋等待。Playwright 的操作方法会自动等待元素可交互,再配合 expect 的重试逻辑,写出来的测试比传统框架稳定一个量级。后面我们做的所有稳定性工作,也都是在这个底子上展开的。

这套方案适合谁参考?你如果维护的是一个工具型应用,尤其是 Electron 桌面应用,或者是一个交互链路比较长的 Web SaaS,都可以把这里的思路直接拿过去用。如果你只写单元测试,没碰过 E2E,这篇文章也能帮你建立一个从零到可落地的完整认知。下面我会从配置、用例设计、稳定性、CI 集成和踩坑经验几个角度,把 Hollama 的完整方案拆开讲。

2. Playwright 工程化配置:从零到可以跑的 E2E

2.1 安装、目录结构与第一份配置

Hollama 的前端仓库是 pnpm workspace,测试目录单独放在e2e/下,与src/平级。这样做的好处是测试代码不会被构建工具误打包,也有独立的tsconfig,不会和业务代码的类型定义纠缠。依赖只需要装一个@playwright/test,其他类似uuid、date-fns这类工具尽量少引,测试代码越零依赖越好维护。

安装完成后,首先需要一份playwright.config.ts。我把它放在e2e/playwright.config.ts,内容一开始很朴素,只有三件事:指定测试文件、指定浏览器、设置 baseURL。

import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests', timeout: 30_000, expect: { timeout: 10_000, }, fullyParallel: true, use: { baseURL: 'http://127.0.0.1:4173', trace: 'retain-on-failure', video: 'off', }, projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, ], });

这里 baseURL 用的不是开发服务器的5173,而是构建预览地址4173。原因很简单:开发服务器带着热更新和额外的编译逻辑,会让 E2E 的启动变慢且不稳定,而vite preview提供的是和生产环境几乎一致的产物。Hollama 的 Electron 生产模式又不太一样,我们后面又单独写了一套启动 Electron 的逻辑,这个留到 2.3 细说。

目录结构上,我们按模块拆分测试文件,而不是把所有用例塞进一个大文件:

e2e/ playwright.config.ts fixtures/ app.ts utils/ mockModel.ts tests/ home.spec.ts session.spec.ts settings.spec.ts model-manage.spec.ts

每个 spec 文件对应一个用户可见的功能域,文件里只写该域的用例。这个拆分方式以后出问题的时候特别好排查:跑挂了看文件名就知道是哪块功能,改一个模块的测试也不会触碰其他模块。

2.2 webServer 和启动编排

大多数 Web 项目的 Playwright 配置里,会直接用webServer指令让 Playwright 在测试前自动启动一个开发服务器。Hollama 也一样,但不是启动 Vite 开发服务器,而是启动一个轻量级的静态资源服务。

我们做了一个scripts/e2e-server.mjs,它只做两件事:先执行构建,然后启动静态服务器托管dist/目录。配置写法:

webServer: { command: 'node ../scripts/e2e-server.mjs', port: 4173, reuseExistingServer: !process.env.CI, timeout: 120_000, }

reuseExistingServer这个参数要重点说。本地开发时,你常常已经把预览服务开着,这时如果 Playwright 强制再拉一个新服务,会端口冲突。设成!process.env.CI后,本地有服务就直接复用,没有就自动拉起;CI 里永远是干净环境,所以每次都新建。这个小小的条件判断给本地调试省了大量时间。

还有一个细节:command里尽量不要写npm run dev这类间接命令,最好直接指向node script.mjs。npm 会包一层 shell,在 Windows 上偶尔会残留子进程,测试结束后服务不退出,CI 就会挂在那里。直接执行 node 脚本,结束得干净利落。

2.3 Electron 桌面端的特殊处理

Hollama 的桌面应用和普通 Web 页面不同,需要 Playwright 直接拉起 Electron 主进程。Playwright 官方支持 Electron,但配置方式不在webServer里,而是要写一个自定义 fixture。

我们需要在 fixture 中启动 Electron 应用,然后把electronApp暴露给每个测试。核心代码如下:

import { test as base, _electron as electron } from '@playwright/test'; export const test = base.extend({ app: async ({}, use) => { const app = await electron.launch({ args: ['.'], cwd: '../dist-electron', env: { ...process.env, E2E_MODE: '1', }, }); await use(app); await app.close(); }, }); export const expect = base.expect;

这段代码里有几个关键点。cwd必须指向 Electron 主进程所在的目录,否则会启动失败。E2E_MODE是一个环境变量,用来让主进程走测试专用的配置,比如禁用自动更新、固定默认模型路径。如果没有这个变量,测试进程会去连接真实的模型服务,结果既慢又不稳定。

启动 Electron 后,测试里通常需要拿它对应的窗口作为操作对象。推荐写法是:

const firstWindow = await app.firstWindow(); await firstWindow.goto('http://127.0.0.1:4173');

为什么手动goto?因为 Electron 的加载逻辑可能会因为环境差异走不同的初始路由,显式指定可以让测试用例聚焦在页面逻辑上,而不用关心壳子的启动细节。

我们测试策略里有一个基本原则:如果功能主要发生在浏览器渲染进程中,就走纯 Web 模式(即vite preview+ normal browser),只在涉及桌面壳交互时才走 Electron fixture。比如“点击菜单栏打开设置”就必须用 Electron,而“在页面中填写提示词并发送”完全可以用普通 Chrome 跑。这样能大幅减少 Electron 实例的数量,CI 并行度也能提上去。

3. 测试用例设计:怎么把核心链路写成能维护的用例

3.1 用例清单和优先级

E2E 用例不是把功能点都列一遍,而是挑用户路径中“失败成本最高”的部分。Hollama 的系统里,我们最终只维护了二十多个 E2E 用例,但每个都覆盖一条真实链路。按优先级排列:

优先级用例名称覆盖链路原因
P0启动后模型列表正确加载启动 → 读取本地配置 → 请求模型服务 → 渲染列表一切操作的前提
P0新建会话并发送第一条消息新建会话 → 输入提示词 → 发送 → 流式接收 → 渲染回复产品的命脉功能
P0切换模型后上下文保留会话中切换模型 → 继续对话 → 历史记录不丢多模型对话的核心卖点
P1参数设置保存并生效打开设置 → 修改温度 → 保存 → 新会话使用新参数影响生成质量
P1模型删除二次确认删除模型 → 确认弹窗 → 模型从列表消失防止误删
P2历史会话列表的增删改查建多个会话 → 重命名 → 删除兜底保障

P0 是每次提交都必须跑的,P1 每天定时跑,P2 只在发布前全量跑。这个分级不是为了偷懒,而是为了控制 CI 时长。E2E 的日常职责是守住主干,而不是把所有细枝末节都揽下来。

3.2 模型列表加载用例的代码拆解

以“启动后模型列表正确加载”为例,看看一个 E2E 用例内部是怎么组织的。

test('启动后模型列表按配置加载', async ({ page }) => { // 预置 mock 数据 await page.addInitScript(() => { window.__E2E_MODELS__ = [ { name: 'llama-3-8b', status: 'ready', size: '4.9GB' }, { name: 'qwen2-7b', status: 'running', size: '6.1GB' }, ]; }); await page.goto('/'); await expect(page.getByRole('heading', { name: '模型库' })).toBeVisible(); await expect(page.getByText('llama-3-8b')).toBeVisible(); await expect(page.getByText('qwen2-7b')).toBeVisible(); // 状态标签的细节验证 await expect(page.getByText('ready').first()).toBeVisible(); await expect(page.getByText('running').first()).toBeVisible(); });

这段代码看起来简单,但每一行都有讲究。page.addInitScript是在页面加载前注入脚本,用来设置一个window.__E2E_MODELS__的测试钩子。业务代码里我们会检测这个全局变量,如果存在就直接用它替代真实服务请求。这是 E2E 测试中非常重要的“测试替身”思路:不是每个用例都要连真实后端,E2E 重点验证的是“数据到了前端之后,界面和交互是否正确”。

getByRole和getByText是推荐的定位方式。一开始我们很多人习惯用 CSS 选择器,比如.model-item或者#model-list > div:nth-child(2),这种定位器对结构和样式变动非常敏感。Playwright 的按角色定位(heading、button、textbox)模拟的是用户感知,重构样式时不会轻易挂。

toBeVisible()默认会自动等待,不需要手动waitForSelector。Playwright 会持续轮询,直到元素出现或者超时。这种写法看起来只是少了几个字母,实际上是把整个调试心智模型从“命令式等待”切换到了“声明式断言”。

3.3 对话场景:流式渲染怎么测

Hollama 的核心功能是流式对话,用户发一条消息,模型会分多次返回 token,页面实时渲染。E2E 测流式有一个天然难点:你无法保证真实模型返回的速度和内容,所以必须 mock 流式接口。

我们在测试环境里对后端请求做了接管,用类似下面的方式模拟分段返回:

await page.route('**/api/chat', async route => { const body = '你好,我是Hollama测试模型'; const chunks = body.split(''); for (const ch of chunks) { await new Promise(resolve => setTimeout(resolve, 30)); } await route.fulfill({ status: 200, contentType: 'text/event-stream', body: chunks.map((c, i) => `data: ${JSON.stringify({ delta: c, index: i })}\n\n`).join(''), }); });

这里用page.route拦截了网络请求,模拟了一个每 30 毫秒返回一个字符的流式响应。测试断言就可以验证“消息块是否逐字出现在页面上”,而不是只验证“最终结果一次性出现”。

这个方式比真实请求强得多:真实模型速度不稳定,会把测试时间拉长到不可接受的范围;mock 后每个用例可以在两秒内稳定跑完,而且完全可重复。有些团队认为 E2E 就必须连真实服务,我觉得是误解。E2E 的价值在于端到端的用户链路,外部依赖一律可以替身化,链路本身才是你要保护的对象。

4. 稳定性:E2E 最容易翻车的地方,怎么一一压住

4.1 定位器的稳定:告别 CSS 与 XPath 依赖

E2E 测试最怕的不是代码 bug,而是“昨天还绿,今天突然变红,但是应用明明没坏”。这类不稳定十有八九是定位器太脆弱。Hollama 早期吃过一次大亏:一个测试用.model-card:has-text("llama")来定位模型卡片,后来 UI 重构把卡片改成了 div 套 div,结果测试挂了三天。

我们现在强制规定:能用getByRole不用getByText,能用getByText不用 CSS 选择器。三者都不行的时候,才在组件里加>await expect(page.getByTestId('stream-output')).toContainText('你好', { timeout: 15_000 });

这行代码的意思是:15 秒内反复检查文本是否出现。如果出现就立刻通过,不出现就等到超时。这就是 Playwright 和开发者之间的“信任关系”:你只管设定业务条件,轮询由框架负责。

需要显式等待的场景其实很少,一个常见场景是等待一个过程完成,但没有 UI 反馈。比如模型加载中,页面显示 loading 动画但最终结果不变。这时可以等待接口响应:

const responsePromise = page.waitForResponse('**/api/models'); await page.click('button:has-text("刷新模型")'); const response = await responsePromise; await expect(response.ok()).toBeTruthy();

这种写法的好处是:测试不是“等三秒”,而是“等一个事件”,三秒后如果接口没返回,自然会报错,不浪费时间。

4.3 依赖隔离:本地路径、时钟和自动更新

Electron 应用最容易受系统环境影响。Hollama 默认会读取用户目录下的配置文件,如果测试环境的用户目录恰好有历史数据,测试结果就会不可预期。因此,所有 E2E 测试启动前都会设置一个临时目录。

我们做了一个 helper:

export function createTempUserDir() { return mkdtempSync(join(tmpdir(), 'hollama-e2e-')); }

在 fixture 启动 Electron 时,把这个临时目录通过环境变量传给应用:

env: { ...process.env, E2E_MODE: '1', HOLLAMA_USER_DIR: tempDir, }

业务代码里判断到这个环境变量后,就会把配置读写目录替换成临时目录。这样每次测试都从干净状态开始,不会因为上一次测试留下的数据而互相干扰。

自动更新也必须禁掉。Electron 应用经常会检查更新,如果测试环境网络不稳定,它会弹窗或者跳转,把页面状态搞乱。E2E_MODE里我们直接app.isPackaged判断,测试模式下不注册更新器。

5. CI 集成:让测试每天自动跑起来

5.1 在无头环境运行 Electron

Hollama 用的 CI 是 Linux 容器,默认没有显示器,所以 Electron 必须用xvfb才能跑起来。Playwright 官方其实已经内置了处理方案,但我在实践中的一个经验是:用xvfb-run包一层更稳。

在 CI 的 shell 脚本里:

xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \ npx playwright test --project=electron

--auto-servernum避免多个 worker 并行时的锁冲突。Electron 本身无法多开太多实例,所以我们在 CI 中把 electron 项目单独拆出来,不让它和其他 Web 测试混在一起跑。

5.2 并行分片与结果报告

Playwright 天然支持并行,多个 spec 文件会跑在多个 worker 上。Hollama 的测试量不大,单个 worker 跑完全程也就五六分钟。但为了后续扩容,我们配置了分片:

npx playwright test --shard=1/3

在 GitHub Actions 之类的 CI 上可以定义三个 job 并行,每个 job 跑三分之一用例。合起来总时间能压到两分钟以内。

报告方面,我们用了 Playwright 自带的 HTML Reporter,同时也输出 JUnit XML,方便 CI 平台解析。关键在于trace: 'retain-on-failure',测试失败时自动保存 trace 文件。trace 是 Playwright 最强大的调试工具,它记录了整个测试过程的快照、网络请求、控制台日志和 DOM 变化。有一次排查一个偶发失败,我直接打开 trace,看到鼠标点击的位置和预想差了几个像素,立刻定位到是页面布局偏移问题。没有 trace,这种问题几乎没法查。

6. 避坑记录:我在 Hollama 测试中踩过的五个坑

6.1 首屏渲染竞态:初始化脚本时机不对

之前我们用page.addInitScript注入测试数据,但有时注入后页面仍然闪了一下真实数据。排查发现是因为注入脚本在页面加载早期执行,但业务代码在模块初始化时会立刻调用真实接口,导致测试数据被后续请求覆盖。

解决方案是:先拦截接口,再注入数据,最后再放行页面逻辑。也就是在addInitScript之前先page.route('**/api/models', fulfill)。流程调整后,数据来源彻底唯一,再也没出现竞态。

6.2 Mock 数据污染了所有用例

我犯过一个错误:在某个测试文件顶层直接调用了page.route,没有放在每个测试内部。结果这个 mock 在该文件所有测试里生效,导致其他用例跑到一半数据格式不对。后来规定:涉及 mock 的 route 一律放在用例内,或者用test.describe单独分组。如果确实需要全文件 mock,也要在afterEach里恢复。

6.3 Electron 窗口找不到元素:焦点问题

Electron 应用启动后,首窗口可能还在加载状态,如果立即执行getByRole找元素,有时元素已经在 DOM 里但不可见。这就是典型的“可见性”问题,不是定位器不对。我们做了一个统一封装:等待窗口的did-finish-load事件后再进行测试操作。代码里是这么写的:

await app.firstWindow().waitForEvent('domcontentloaded');

实测下来,这个事件比load更快也更稳,而且能避开 Electron 预加载脚本的时序坑。

6.4 并行 worker 共享同一个临时目录

我们最初把所有用例放在同一个临时目录下,并行跑的时候两个用例同时写一个文件,导致偶发错误。后来改成每个测试用唯一目录,直接用测试标题生成目录名,问题彻底消失。这属于典型的“测试隔离”问题,E2E 测试的隔离性比单元测试更重要,因为涉及真实文件系统。

6.5 网络请求超时导致用例无端失败

Hollama 有部分功能会尝试联网拉取模型信息,测试环境如果断网,请求会一直卡到超时。我们在 fixture 启动时统一拦截外部请求,只允许访问本地服务:

await context.route(/^https?:\/\/(?!127\.0\.0\.1)/, route => route.abort());

这段正则的意思是:所有非本机地址的 HTTP/HTTPS 请求一律直接中止。外部依赖全部切断后,测试速度和稳定性都明显提升。这也符合端到端测试的定位:你验证的是自家应用的链路,而不是外网质量。

7. 最后想说的

测试策略没有唯一标准答案,但有一点我很确定:E2E 测试不是越多越好,而是越核心越好。Hollama 用 Playwright 搭建的这套体系,前期大概花了我们两周的精力去配置、写用例、调稳定性,之后每天带给我们的收益远超投入。它不负责发现每一个逻辑 bug,但负责在每次发版前告诉我们:“用户最常走的几条路,现在还是通的。”

如果你正准备为一个项目引入 E2E 测试,我建议你从一条链路开始,比如“用户注册并创建第一条记录”,把它跑通、跑稳,再慢慢扩散。如果你已经在写 E2E,试着问自己:每个用例是不是都从用户角度出发?是不是都能在 CI 里稳定重复地给出结果?离线运行是否彻底隔离了外部依赖?这些问题想清楚,测试就会从“负担”变成“底气”。

最后再分享一个实际操作里的小技巧:把 Playwright 的 trace 设为retain-on-failure,暂时不要为了省存储而关闭它。等你第一次通过 trace 找到那个极其诡异的失败原因,你会感谢这个设置。测试的路不好走,但每一份稳定,都是给项目积攒的信任。

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

本地Web应用自动化测试实战:从环境搭建到稳定回归

在本地开发 Web 应用的那段时间,最耗我的不是写业务逻辑,而是手动验证同一个流程。打开浏览器、填表单、点提交、等跳转、看结果,这套动作一天重复几十遍,眼睛容易看花,手也容易点错,回归测试更是经常漏掉边…

作者头像 李华
网站建设 2026/10/21 19:55:27

Python批量文件清理:规则引擎、扫描与安全执行全指南

简介:资源聚焦Python自动化批量文件管理中的筛选场景,面向具备Python基础的技术人员与学生,旨在解决杂乱文件夹中按自定义规则快速定位特定文件的效率问题。资源为1个docx文档,压缩包大小14KB,文档内包含完整可运行的代…

作者头像 李华
网站建设 2026/10/21 19:53:13

51单片机校车安全检测系统:气体传感器+WiFi+舵机联动调试

这两年陆续帮好几个学弟学妹调过校车安全检测方向的单片机项目,踩过的坑比想象中多。很多人照着网上的教程把模块买齐、代码烧进去,就觉得万事大吉,结果一联调就露馅:ADC读数上下乱飘、ESP8266发AT指令没反应、舵机一动作整个板子…

作者头像 李华
网站建设 2026/10/21 19:53:02

diagram-design:结构化表达与图形选型的工程实践指南

1. 从“diagram-design”这个标题说起:它到底在解决什么问题第一次看到“diagram-design”这个标题,很多人会下意识觉得它只是“画图”或者“做设计稿”的另一个说法。但真正在一线做过系统设计、技术方案评审、跨团队协作的人会明白,这个标题…

作者头像 李华
网站建设 2026/10/21 19:52:27

二进制对比与十六进制编辑实战:固件分析与数据恢复指南

干固件、嵌入式、逆向分析、数据恢复这一行的,十有八九都碰上过这种场景:手里两个BIN文件,一个能用、一个跑不起来,或者升级前后的固件想确认只改了哪几个字节,结果用文本工具打开全是乱码,用普通比对工具一…

作者头像 李华
网站建设 2026/10/21 19:50:39

告别本地工控机:分布式光伏 SCADA 架构如何实现云边协同

去年 5 月,我们在苏南跑一个 20MW 的分布式项目,资产方要求实现秒级监控。当时施工方为了省事,还是按地面站那套逻辑,在现场塞了个工业 PC 装本地 SCADA。结果上线不到一周,运维群里的告警就炸了:网络稍微一…

作者头像 李华