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 工程化配置从零到可以跑的 E2E2.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 测试启动前都会设置一个临时目录。我们做了一个 helperexport 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 在无头环境运行 ElectronHollama 用的 CI 是 Linux 容器默认没有显示器所以 Electron 必须用xvfb才能跑起来。Playwright 官方其实已经内置了处理方案但我在实践中的一个经验是用xvfb-run包一层更稳。在 CI 的 shell 脚本里xvfb-run --auto-servernum --server-args-screen 0 1920x1080x24 \ npx playwright test --projectelectron--auto-servernum避免多个 worker 并行时的锁冲突。Electron 本身无法多开太多实例所以我们在 CI 中把 electron 项目单独拆出来不让它和其他 Web 测试混在一起跑。5.2 并行分片与结果报告Playwright 天然支持并行多个 spec 文件会跑在多个 worker 上。Hollama 的测试量不大单个 worker 跑完全程也就五六分钟。但为了后续扩容我们配置了分片npx playwright test --shard1/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 找到那个极其诡异的失败原因你会感谢这个设置。测试的路不好走但每一份稳定都是给项目积攒的信任。
阅读完成 · 觉得有帮助?