本文基于 LLM Proxy 项目(FastAPI 单进程单端口 + Vue3 管理台)的真实实践:从零搭建 Playwright E2E 工程,
按 OpenSpec 需求规格驱动编写 66 个用例,全部自动化运行、失败自动取证、与规格逐条对账判定产品行为一致性,
最终 66/66 通过并归档。文中所有命令、坑位与修复均来自实际过程。
1. 背景与目标
被测试系统:
- 后端:FastAPI + uvicorn,单进程单端口(3002),网关面
/v1/*+ 管理 API/api/v1/admin/*+ 托管 Vue3 管理台(SPA 兜底) - 前端:Vue3 + TS + Element Plus + ECharts
- 需求基线:OpenSpec 规格(
openspec/specs/下 8 个 capability,每个 capability 由若干 Requirement + Scenario 组成,MUST/MUST NOT 措辞)
目标:
- 可视化:能自动打开 Playwright 自带的 Chromium,看到真实的鼠标点击、输入、页面跳转
- 自动化:一条命令完成「拉起服务 → 启动浏览器 → 登录 → 逐页测试 → 失败取证 → 回收进程」
- 规格对账:测试用例与 OpenSpec 的 Requirement/Scenario 一一对应,测试结果能回答"产品行为是否符合需求规格"
2. OMP 中需要安装什么
2.1 Node.js 运行时
Playwright 测试运行器要求 Node 18+(本机实测 Node v24 正常)。OMP 的 bash/eval 工具链与 Node 无额外耦合。
2.2 Playwright 测试框架 + 浏览器
在仓库根目录(而不是web/前端工程里)建独立 e2e 工程,与前端构建解耦:
# 根目录 package.json(若无则新建),添加:npminstall-D@playwright/test--registry=https://registry.npmmirror.com# 安装 Chromium 浏览器(国内镜像,避免官方 CDN 不可达)# Windows PowerShell:$env:PLAYWRIGHT_DOWNLOAD_HOST="https://cdn.npmmirror.com/binaries/playwright"npx playwrightinstallchromium镜像参数只在本机网络受限时需要;网络正常时
npm i -D @playwright/test && npx playwright install chromium即可。node_modules/已被 .gitignore 覆盖,浏览器二进制在用户目录,均不入库。
package.json的 scripts(本次工程的全部命令面):
{"scripts":{"test:e2e":"playwright test","test:e2e:headed":"playwright test --headed","test:e2e:ui":"playwright test --ui","test:e2e:report":"playwright show-report","test:e2e:install":"playwright install chromium"}}2.3 OMP 侧的能力(无需安装,内置)
OMP 提供三类互补能力,Playwright 是主力,其余用于辅助:
| 能力 | 用途 |
|---|---|
| browser 设备(内置 Chromium 驱动) | 手工走查、抓页面 a11y 快照、截图取证;用于"先人工看一遍"与失败后定位(如本次用tab.observe()拿到收起态菜单的真实 DOM 结构再决定定位器) |
| e2e-testing 技能 / e2e-runner 代理 | 页面导航、断言、失败隔离的通用模式参考 |
| task 子代理 / eval 内核 | 并行读规格文件、跑对比脚本(如 delta 规格与主规格的需求/场景计数比对) |
2.4 关键结论
OMP 本身"自带浏览器"(browser 设备),但成体系的回归测试必须落在 Playwright 上:
browser 设备适合交互式取证(单次会话、即时观察),Playwright 适合可重复、可 CI、可留证(截图/trace/视频)的正式测试资产。
两者分工:browser 设备做探索与定位,Playwright 做回归与验收。
3. 工程结构与配置
llmproxy/ ├── playwright.config.ts # 全局配置 ├── package.json # 仅 e2e 依赖与脚本(与 web/ 解耦) └── tests/e2e/ ├── auth.ts # admin fixture:API 登录取 JWT → addInitScript 注入 localStorage ├── helpers.ts # API helper:建/删用户、建/删 key、热设置读改恢复、实时查询窗口 ├── login.spec.ts # 登录 UI 真实点击(成功/错误密码/限流 429) ├── admin-pages.spec.ts # 10 个管理页渲染冒烟 ├── api.spec.ts # 网关面/管理面接口契约冒烟 ├── gateway-e2e.spec.ts # 真实上游全链路(含全量审计归档验证) └── specs/ # 8 个 OpenSpec capability 的规格驱动 spec ├── user-auth.spec.ts ├── user-management.spec.ts ├── traffic-stats.spec.ts ├── dashboard-user-usage.spec.ts ├── request-audit.spec.ts ├── full-client-audit.spec.ts ├── dark-mode.spec.ts └── menu-collapse.spec.tsplaywright.config.ts核心(决定"一条命令全自动"的关键):
exportdefaultdefineConfig({testDir:'./tests/e2e',timeout:30_000,retries:2,workers:1,// 串行:用例共享真实 DB/限流桶/热设置,保证确定性reporter:[['list'],['html',{open:'never'}]],use:{baseURL:'http://127.0.0.1:3002',trace:'on-first-retry',// 首次失败后重试自动录 tracescreenshot:'on',},webServer:{// 服务未运行则自动拉起;跑完自动回收;已运行则复用command:process.platform==='win32'?'venv\\Scripts\\uvicorn.exe main:app --port 3002':'venv/bin/uvicorn main:app --port 3002',url:'http://127.0.0.1:3002',reuseExistingServer:true,timeout:60_000,},})两个实践要点:
- webServer 平台条件命令:Windows 的 cmd 不认正斜杠相对路径(
venv/bin/uvicorn直接报'venv' is not recognized)。之前该问题被reuseExistingServer掩盖(服务一直在跑),
一旦服务未起、Playwright 自己拉起就暴露——webServer 的拉起路径必须单独验证过。 workers: 1是规格对账的前提:登录限流桶、热设置、审计归档目标都是全局单例状态,
并发用例会互相污染(本次实测:限流用例把 429 桶灌满后,后续同文件用例全部 429)。
4. 怎么自动启动 Chromium(三种模式 + 服务联动)
| 模式 | 命令 | 效果 |
|---|---|---|
| 无头(CI/默认) | npm run test:e2e | 不显示窗口,速度最快 |
| 有头(看操作) | npm run test:e2e:headed | 自动打开 Chromium 窗口,全程可见鼠标点击/输入/跳转 |
| UI 模式(交互式调试) | npm run test:e2e:ui | Playwright 可视化面板:单步执行、断点、逐用例重跑、查看每步的网络请求/DOM/截图 |
| 手动录制 | npx playwright codegen http://127.0.0.1:3002 | 打开受控 Chromium,你手工点,它实时生成定位器代码 |
"自动"的完整链条(一条npm run test:e2e:headed触发):
npm script └─ playwright test ├─ webServer: 探测 3002 端口 │ ├─ 未运行 → spawn uvicorn(平台条件命令)→ 等待 URL 可达 │ └─ 已运行 → 复用(reuseExistingServer) ├─ 按 workers:1 串行执行 spec │ └─ 每个 test 自动分配一个 Chromium 页面(有头模式下是同一窗口内的 tab) │ ├─ goto baseURL → 路由守卫 → /login │ ├─ fixture 注入会话 或 真实 UI 填密码点登录 │ └─ 逐条断言(失败 → 截图 + 重试 → 仍失败 → trace.zip) └─ 全部结束 → 回收自己拉起的 uvicorn 进程登录会话的双通道设计(既覆盖 UI 登录流程,又避免每个用例重复点登录):
// tests/e2e/auth.ts —— fixture 通道:API 登录取 JWT,注入 localStorageexportconsttest=base.extend<{page:Page}>({page:async({page},use)=>{consttoken=awaitapiLogin()// POST /api/v1/admin/login,密码读 .envawaitpage.addInitScript(([t,r])=>{localStorage.setItem('llmproxy_token',t)localStorage.setItem('llmproxy_role',r)},[token,'admin'])awaituse(page)},})login.spec.ts走真实 UI 点击(填密码 → 点「登录」→ 断言跳转/dashboard,含错误密码/限流场景)- 其余 60+ 用例走fixture 注入,直接进入目标页
- 密码绝不硬编码:
adminPassword()从环境变量或项目根.env的ADMIN_PASSWORD读取
5. 自动化测试的完整流程(八步)
① 需求基线:OpenSpec 规格是唯一验收标准
openspec/specs/<capability>/spec.md中每条 Requirement 用 MUST/MUST NOT 描述行为,
每条至少一个#### Scenario(WHEN/THEN/AND)。例如 full-client-audit:
### Requirement: 仅目标客户端被全量归档 ... 系统 MUST 对目标客户端的每次请求保存完整请求/应答 ... #### Scenario: 目标客户端产生归档 - **WHEN** 对目标客户端发起一次请求 - **THEN** 归档列表中出现该请求的元数据,且请求内容逐字可查规格里没有写过的行为,测试不应该断言;规格里 MUST 写过的行为,测试必须覆盖或显式声明归其他层。
② 测试计划:映射 + 边界声明
把每个 capability 的 Requirement/Scenario 映射成 spec 文件与用例,用例标题直接引用规格:
test('[full-client-audit] 仅目标客户端被全量归档:请求逐字 + 信封打码 + 可下载',async()=>{...})同时显式声明 E2E 边界(哪些场景不属于 E2E,归 pytest 集成测试),写进文件头注释:
// 说明:DB 明细对账、缓存口径、流式逐块事件流等深度场景归 pytest 集成测试// 上游服务不可用时整文件跳过(本地开发环境依赖,非产品缺陷)边界声明是"判定一致性"的前提——只有明确了每个 Scenario 的归属层,才能说"该层内全部覆盖"。
③ 编写测试脚本
约定(本项目执行下来的纪律):
- 可抛数据
e2e_前缀 +afterAll强制清理(含 key 删除保护场景的兜底清理函数),不污染真实注册表 - 热设置读改恢复:改全局参数(如
full_audit_client_id)一律走withSetting(),异常路径也恢复原值 - API 断言走 APIRequestContext:不依赖页面渲染的内部状态,直接对接口契约断言
- UI 断言优先可访问名称/角色(
getByRole/getByLabel),其次 CSS 类,最后才是位置索引(并注释原因) - 外部依赖降级:全链路依赖真实上游(vLLM/Ollama),不健康时
test.skip整文件,不产生假失败
④ 执行
npmrun test:e2e# CI:无头npmrun test:e2e:headed# 日常:有头窗口看全程npmrun test:e2e:ui# 调试:UI 模式单步本次全量 66 用例,真实上游在环,约 64 秒。
⑤ 取证(截图 / trace / 快照)
失败时 Playwright 自动产出(test-results/):
test-failed-*.png:失败瞬间截图trace.zip(首次重试起自动录):npx playwright show-trace回放每一步的 DOM/网络/控制台error-context.md:a11y 树快照——本次的关键证据之一。收起态菜单定位超时,
从快照里直接看到 10 个menuitem都没有可访问名、且 Element Plus 的index是 prop 不落 DOM 属性,
据此决定按 DOM 顺序.nth(n)定位(注释标明顺序来源)
此外 OMP browser 设备可做主动补拍:打开真实页面、执行 JS 取outerHTML/boundingBox、tab.screenshot()存 webp。本次用它实测了 el-aside 宽度(220↔64px)、localStorage 键名、
菜单折叠后的真实 DOM,把"猜选择器"变成"量选择器"。
⑥ 根因分析:先分诊「产品 bug」还是「测试 bug」
本次 66 个用例跑绿之前共出现 11 个失败,没有一个是产品 bug,全部是测试侧问题——
这个分诊过程本身就是流程价值:
| 现象 | 分诊结论 | 修复 |
|---|---|---|
| 后续用例批量 429 | 限流用例污染了 60s 失败桶(共享状态) | 限流用例移到文件末尾(串行下确定不污染前置用例) |
| 密码框找不到 | 输入用户名后 placeholder 动态变化 | 改用可访问名称(getByRole('textbox', {name:'密码'})) |
chat 返回content: '' | 疑似网关丢内容 → 实测对照:thinking 模型max_tokens=16被思考过程吃光(finish_reason=length),改 512 正常 →网关透传正确,测试参数问题 | 测试max_tokens512 |
| 归档列表恒 0 行 | 疑似归档器失效 → 读代码:created_at为 UTCYYYY-MM-DD HH:MM:SS,测试只传日期,字典序比较恒假 →测试参数格式问题 | 查询窗口改完整 UTC 时间戳,且每次查询实时计算(模块加载时冻结的窗口在 60s 后就过期) |
| 宽度断言抖动 | CSS 过渡动画竞态(点击后立即读 boundingBox 可能还在 220) | expect.poll(...).toBeCloseTo(64)轮询等稳定 |
分诊手段排序:看 error-context 快照 → 读产品代码确认契约 → 用独立脚本直连 API/上游对照实测(本次用一次性 node 脚本对比"网关 vs 上游直连",2.6 秒定案)。
⑦ 与 OpenSpec 需求规格对比,判定功能是否正常
对账是三步:
- 覆盖对账(规格 → 测试):逐条 Requirement/Scenario 问"谁覆盖它?"
- 被 E2E 用例覆盖 → 该用例必须绿
- 被声明归 pytest 层 → 查 pytest 套件
- 两层都没有 →缺口,补用例或补声明
- 断言对账(用例 → 规格):用例断言必须落在规格的 WHEN/THEN/AND 上,
且规格中每条 MUST/MUST NOT 至少被一个断言守住;反向也查——用例断言了规格没有的行为是过度测试,应删除 - 反向路径对账:MUST NOT(“厂商 key 不可用”、“路径穿越被拒”、“key 明文不回显”)
必须用反向用例验证"该发生的不发生",只有正向通过不算覆盖
一致性判定标准(全部满足才判定"该 capability 功能与需求一致"):
| # | 判据 | 反例(本次实际踩过) |
|---|---|---|
| 1 | 该 capability 所有 E2E 归属 Scenario 的用例全绿且无 flaky(重试后通过要按未通过处理,查根因) | 限流桶污染导致 1 个用例"重试后通过",实际是顺序侥幸 |
| 2 | 断言的是规格措辞本身(状态码、error.code、字段存在/打码/逐字、可访问性、无溢出),不是实现细节 | 断言toBeTypeOf('number')(Jest API)——断言对象错位,Playwright expect 根本没有该方法 |
| 3 | 正向路径用真实数据/真实上游验证,不 mock 被测系统本身 | 用假模型名跑 chat,永远测不到网关-上游翻译层 |
| 4 | 数据可对账:写后能读回且一致(归档内容逐字含 marker、打码处不含明文 key、下载文件与详情一致) | 只断言"列表有 1 行"而不验内容,无法证明"逐字归档" |
| 5 | 用例可重跑:跑两遍结果相同(可抛数据已清理、全局状态已恢复、时间窗口实时计算) | 模块级冻结的查询窗口导致第二遍必挂 |
⑧ 回归与交付
- 全量绿 → 把"怎么跑"写进 README(命令、映射表、边界声明)
- 提交时按仓库惯例引用 OpenSpec 变更名;规格随
/opsx-sync同步入主规格、/opsx-archive归档 - 产物(test-results/、playwright-report/、node_modules/、浏览器二进制)全部 gitignore,不入库
6. OMP 在该流程中的角色(人机分工)
| 阶段 | OMP 做了什么 | 人做什么 |
|---|---|---|
| 测试计划 | 读 8 个 capability 规格,产出映射表与边界声明 | 确认边界划分(什么归 E2E、什么归 pytest) |
| 编写 | 生成全部 spec/helpers/fixture/config | 审阅选择器策略与断言强度 |
| 执行 | npm run test:e2e:headed一条命令;browser 设备手工补拍取证 | 看窗口里的真实操作(有头模式的价值) |
| 分析 | 读 error-context 快照、读产品代码、跑对照探测脚本分诊 11 个失败 | 裁定"测试 bug / 产品 bug"的争议项 |
| 对账 | 逐 Requirement 核对覆盖与断言,产出判定结论 | 拍板"功能与需求一致"的结论 |
| 交付 | README 章节、提交(引用变更名)、归档 | 确认提交与推送时机 |
核心原则:判定权在人,取证与对账的体力活给 OMP。
“功能是否正常"不是"测试绿了”,而是"规格里的每条 MUST 都有对应用例守住、且全部通过"。
7. 常见坑清单(Windows + Vue/Element Plus + 网关类系统)
- Windows 下 webServer 命令:cmd 不认正斜杠相对路径 → 平台条件命令;
reuseExistingServer会掩盖拉起失败,必须服务未起时验证一次 - 限流/计数器类共享状态:会失败的用例(429 桶、预算)放文件末尾,
workers: 1 - 动态 placeholder/文案:表单字段文案随输入变化时,用可访问名称(label/role)而非 placeholder
- CSS 过渡动画:宽度/位移类断言用
expect.poll等稳定值,不要点击后立即读 - Element Plus 折叠菜单:
index是 prop 不落 DOM;收起态文字标签不可访问 → 按 DOM 顺序.nth(n)并注释顺序来源 - 时间窗口与时区:先读产品代码确认存储格式(本项目
created_at= UTCYYYY-MM-DD HH:MM:SS),查询参数用完整时间戳且实时计算,不要只传日期、不要模块加载时冻结 - thinking 模型的
max_tokens:短预算会被思考过程吃光(finish_reason=length、content 空)——E2E 用 512 起步;这也是"网关丢没丢内容"分诊的第一步 - 归档/异步落盘:非阻塞写入后查询要留缓冲,且查询窗口要包含缓冲
- 测试 API 方言:Playwright expect 是 Chai 系(无
toBeTypeOf);断言 JSON 对象内容先JSON.stringify - npm 源:国内网络
--registry=https://registry.npmmirror.com+PLAYWRIGHT_DOWNLOAD_HOST=https://cdn.npmmirror.com/binaries/playwright
8. 命令速查
npmrun test:e2e:headed# 有头自动跑(看鼠标操作)npmrun test:e2e:ui# UI 模式单步调试npmrun test:e2e:report# HTML 报告npx playwright show-trace test-results/<case>/trace.zip# 回放失败 tracenpx playwright codegen http://127.0.0.1:3002# 录制openspec list--json# 活跃变更(对账基线来源)9. 总结
OMP + Playwright 的 Web 自动化测试,本质是把「OpenSpec 规格 → 测试计划 → 用例 → 执行 → 取证 → 对账」
做成一条可重复的流水线:
- 装:
@playwright/test+ chromium(国内加镜像参数),OMP 侧用内置 browser 设备做探索与补拍 - 启:
playwright.config.ts的webServer让"服务 + Chromium"随测试命令自动起停;headed/UI 模式按需选 - 测:登录双通道(UI 点击 + JWT 注入)、
e2e_数据自清理、串行保证共享状态确定性 - 流程:规格基线 → 映射计划(含边界声明)→ 规格驱动脚本 → 自动执行 → 截图/trace/a11y 快照取证 → 产品/测试分诊 → 与规格三向对账 → 回归交付
- 判定:功能与需求一致 = 每条 MUST 都有用例守住 + 全绿无 flaky + 断言落在规格措辞上 + 真实数据可对账 + 可重跑
测试绿了只是必要条件;"规格里写的每件事都被验证过"才是交付标准。