Midscene.js与Playwright融合:提升75%自动化测试效率的工程实践
1. 项目概述当Midscene.js遇上Playwright最近在团队里搞了个挺有意思的实践把Midscene.js和Playwright这两玩意儿给揉到了一块儿折腾下来我们几个核心业务线的自动化测试效率保守估计提升了得有75%。这数字听起来有点唬人但确实是实打实跑出来的。很多朋友可能对这两个工具还不太熟简单来说Playwright是微软开源的一个现代Web自动化测试框架支持多浏览器、多语言写起UI测试来那叫一个丝滑而Midscene.js你可以把它理解为一个“场景编排器”或者“测试流程的智能胶水”它本身不直接操作浏览器但特别擅长把复杂的、多步骤的测试用例用一种更结构化、更易维护的方式描述出来并且能和一些AI能力做结合实现测试步骤的智能生成或优化。我们当时面临的痛点很典型业务迭代快UI变更频繁传统的基于Selenium或者纯Playwright脚本的维护成本高得吓人。测试同学不是在改脚本就是在去改脚本的路上。引入Midscene.js的核心思路就是想把“测什么”业务场景和“怎么测”浏览器操作解耦。让Midscene.js来负责定义高层的、稳定的业务场景流比如“用户登录-搜索商品-加入购物车-下单支付”而Playwright则作为底层的“执行引擎”精准地完成每一个具体的点击、输入、断言操作。这个组合拳打下来脚本的稳定性、可读性和可维护性都上了一个大台阶效率提升自然水到渠成。这篇文章我就来详细拆解一下我们是怎么把这两者融合起来的从设计思路、技术选型、具体实现到踩过的坑和总结的心得都会毫无保留地分享出来。无论你是正在被自动化测试维护成本困扰的测试开发还是对新兴测试框架和模式感兴趣的前后端工程师相信都能从中找到一些可以直接“抄作业”的点子。2. 核心架构设计与融合思路拆解2.1 为什么是Midscene.js Playwright在决定采用这个技术栈之前我们评估过不少方案。纯Playwright脚本对于中小型项目或者固定页面是利器但面对我们这种拥有上百个页面、业务流程错综复杂的电商平台脚本很快就变成了“意大利面条”代码牵一发而动全身。而一些传统的BDD框架如Cucumber虽然解决了场景描述的问题但步骤定义Step Definitions的编写和维护依然是个体力活并且和AI结合的门槛较高。Midscene.js吸引我们的点在于它的“声明式”和“可组合性”。它允许我们用YAML或JSON这类对人类更友好的格式来描述测试场景一个场景就像一篇结构清晰的文档。更重要的是它的架构是插件化的可以轻松接入各种“执行器”Executor——这正是Playwright可以完美嵌入的位置。我们看中的是这个组合带来的分层优势业务层Midscene.js关注点在于“用户故事”和“业务规则”。测试用例的编写者甚至是产品经理可以更直观地理解或参与定义测试场景。这一层的稳定性极高因为只要业务逻辑不变场景描述就不需要改动。操作层Playwright关注点在于“如何与页面交互”。Playwright强大的API用于处理所有细节元素定位、网络拦截、文件上传、多标签页等。这一层需要应对前端变化但得益于Playwright强大的选择器和自动等待机制已经比Selenium时代稳健太多。连接层自定义适配器这是我们的核心工作需要编写一个适配器Adapter将Midscene.js场景中定义的抽象步骤如type: “输入用户名”映射到具体的Playwright代码如page.fill(‘#username’, ‘testuser’)。这个架构的另一个巨大潜力在于与AI的融合。Midscene.js的设计哲学与当前火热的MCPModel Context Protocol等AI代理协议有相通之处。我们可以利用AI来辅助完成两件事一是根据自然语言或产品文档自动生成初始的Midscene场景描述二是在页面元素发生微小变动时AI可以辅助分析并建议更新对应的Playwright定位器而不是让测试工程师盲目地全网搜索修改点。2.2 整体技术栈与选型考量我们的最终技术栈构成如下每一部分的选择都有其背后的考量场景定义层Midscene.js (YAML格式)。选择YAML而非JSON是因为YAML在编写多行字符串、添加注释时更加清晰可读性更强更适合作为“活文档”使用。执行引擎Playwright for Node.js。选择Node.js版本而非Python或Java主要基于两点一是与Midscene.js一个JavaScript/TypeScript生态的工具集成更原生、更顺畅二是Playwright for Node.js的API更新最及时社区也最活跃。我们放弃了Selenium因为Playwright在速度、稳定性、功能丰富性如自动录制、网络拦截上都有明显代差优势。运行时Node.js (v18)。确保支持最新的ES模块和异步语法。测试运行器Jest。虽然Playwright Test本身也是一个优秀的运行器但我们选择Jest是出于历史原因和统一的断言风格。Jest的钩子函数beforeAll, beforeEach、快照测试等功能我们也在广泛使用。这并不冲突Playwright负责浏览器交互Jest负责测试生命周期管理和断言。AI辅助探索性结合OpenAI API或本地部署的大模型。我们构建了一个内部CLI工具可以读取产品需求文档PRD的某个章节调用AI生成初步的Midscene场景YAML骨架大大提升了用例设计阶段的效率。注意这里没有选择“Playwright Test Agents”等分布式方案是因为我们当前阶段的瓶颈在于脚本创作和维护效率而非执行速度。当脚本稳定后利用Playwright自身的并行能力和CI/CD的矩阵执行已足够满足日常需求。盲目上马复杂分布式系统会引入新的维护成本。2.3 关键设计模式适配器模式与步骤仓库融合的核心是适配器模式Adapter Pattern。我们不希望Midscene场景描述里直接出现Playwright的API调用那样就失去了解耦的意义。我们设计了一个PlaywrightExecutor类它实现了Midscene.js所期望的执行器接口。这个执行器的核心是一个步骤仓库Step Registry。我们预先将常见的UI操作封装成一个个可复用的“步骤”并注册到仓库中。例如navigateTo(url): 打开指定URL。fill(selector, value): 向指定元素输入内容。click(selector): 点击指定元素。assertText(selector, expectedText): 断言元素文本。selectOption(selector, value): 选择下拉框选项。在Midscene的YAML场景文件中我们这样使用name: “用户登录并搜索商品” scenes: - name: “打开登录页” steps: - action: navigateTo params: url: “https://example.com/login” - name: “输入凭据并登录” steps: - action: fill params: selector: “#username” value: “{{test_user}}” - action: fill params: selector: “#password” value: “{{test_password}}” - action: click params: selector: “button[type‘submit’]”当Midscene.js解析这个YAML文件并执行时它会调用PlaywrightExecutor执行器则根据action名称从仓库中找到对应的函数如fill并将params传递给它最终这个函数内部调用page.fill(selector, value)完成操作。为什么大费周章搞个仓库直接写Playwright代码不香吗香但只香一时。步骤仓库带来了几个长远好处一是统一操作所有脚本对“点击”的行为定义是一致的比如都内置了重试和等待二是降低维护成本如果某个组件的定位方式从ID改为data-testid你只需要更新仓库里的一个步骤函数所有用到该操作的场景文件都自动生效三是为AI集成铺路AI可以更容易地理解和使用这些有限的、定义良好的原子操作来组合成复杂场景。3. 融合方案的具体实现与配置3.1 环境搭建与项目初始化首先你需要一个干净的Node.js项目。我们推荐使用pnpm作为包管理器速度更快。# 初始化项目 mkdir midscene-playwright-demo cd midscene-playwright-demo npm init -y # 或使用 pnpm pnpm init # 安装核心依赖 pnpm add playwright midscene.js jest types/jest ts-node typescript -D # 安装Playwright浏览器建议使用项目内安装避免全局依赖冲突 pnpm exec playwright install chromium firefox webkit接下来配置TypeScripttsconfig.json和Jestjest.config.js以支持现代语法和测试运行。这里给出一个最简化的配置参考tsconfig.json:{ “compilerOptions”: { “target”: “ES2022”, “module”: “commonjs”, “lib”: [“ES2022”], “outDir”: “./dist”, “rootDir”: “./src”, “strict”: true, “esModuleInterop”: true, “skipLibCheck”: true, “forceConsistentCasingInFileNames”: true, “resolveJsonModule”: true }, “include”: [“src/**/*”], “exclude”: [“node_modules”, “dist”] }jest.config.js:module.exports { preset: ‘ts-jest’, testEnvironment: ‘node’, testMatch: [‘**/__tests__/**/*.ts’, ‘**/?(*.)(spec|test).ts’], transform: { ‘^.\\.ts$’: ‘ts-jest’, }, };3.2 核心适配器PlaywrightExecutor实现这是整个融合方案的心脏。我们在src/executor/playwright-executor.ts中创建它。import { Browser, BrowserContext, Page, chromium } from ‘playwright’; import { Executor, StepResult, Scenario } from ‘midscene.js’; // 假设midscene.js有这些类型导出 export class PlaywrightExecutor implements Executor { private browser: Browser | null null; private context: BrowserContext | null null; public page: Page | null null; private stepRegistry: Mapstring, Function new Map(); constructor() { this.registerCoreSteps(); } // 1. 初始化浏览器环境 async initialize(config?: any): Promisevoid { this.browser await chromium.launch({ headless: config?.headless ?? true, // 默认无头模式调试时可设为false slowMo: config?.slowMo ?? 50, // 慢放操作方便观察 }); this.context await this.browser.newContext({ viewport: { width: 1920, height: 1080 }, recordVideo: config?.recordVideo ? { dir: ‘./test-results/videos’ } : undefined, }); this.page await this.context.newPage(); } // 2. 注册原子操作步骤到仓库 private registerCoreSteps(): void { this.stepRegistry.set(‘navigateTo’, this.navigateTo.bind(this)); this.stepRegistry.set(‘fill’, this.fill.bind(this)); this.stepRegistry.set(‘click’, this.click.bind(this)); this.stepRegistry.set(‘assertText’, this.assertText.bind(this)); // … 可以注册更多步骤 } // 3. 实现具体的步骤函数 private async navigateTo(params: any): PromiseStepResult { if (!this.page) throw new Error(‘Page not initialized’); await this.page.goto(params.url, { waitUntil: ‘networkidle’ }); return { success: true, message: Navigated to ${params.url} }; } private async fill(params: any): PromiseStepResult { if (!this.page) throw new Error(‘Page not initialized’); // 这里可以添加智能等待确保元素可见、可交互 const selector params.selector; const value this.resolveValue(params.value); // 支持解析变量如 {{user}} await this.page.waitForSelector(selector, { state: ‘visible’ }); await this.page.fill(selector, value); return { success: true, message: Filled ${selector} with ${value} }; } private async click(params: any): PromiseStepResult { if (!this.page) throw new Error(‘Page not initialized’); const selector params.selector; await this.page.waitForSelector(selector, { state: ‘visible’ }); await this.page.click(selector); return { success: true, message: Clicked on ${selector} }; } private async assertText(params: any): PromiseStepResult { if (!this.page) throw new Error(‘Page not initialized’); const selector params.selector; const expected this.resolveValue(params.expectedText); await this.page.waitForSelector(selector); const actualText await this.page.textContent(selector); if (actualText?.trim() ! expected.trim()) { return { success: false, message: Assertion failed: expected “${expected}”, got “${actualText}”, }; } return { success: true, message: Text assertion passed for ${selector} }; } // 4. 解析场景中的变量如 {{test_user}} private resolveValue(input: any, context?: any): any { if (typeof input ! ‘string’) return input; // 简单的变量替换实际项目可用更强大的模板引擎 return input.replace(/\{\{(\w)\}\}/g, (_, key) context?.[key] ?? process.env[key] ?? ‘’); } // 5. 执行单个步骤Midscene.js框架会调用此方法 async executeStep(step: any, context?: any): PromiseStepResult { const action step.action; const stepFunc this.stepRegistry.get(action); if (!stepFunc) { return { success: false, message: Unknown action: ${action} }; } try { return await stepFunc(step.params, context); } catch (error) { return { success: false, message: Error executing ${action}: ${error instanceof Error ? error.message : String(error)}, }; } } // 6. 清理资源 async cleanup(): Promisevoid { await this.page?.close(); await this.context?.close(); await this.browser?.close(); } }这个执行器类完成了从Midscene抽象步骤到Playwright具体操作的关键转换。executeStep方法是桥梁它根据步骤名从仓库调用对应的Playwright函数。3.3 Midscene场景定义与组织规范有了执行器接下来就是如何优雅地组织测试场景。我们建立了以下目录结构tests/ ├── scenarios/ # 存放Midscene场景YAML文件 │ ├── auth/ # 按业务模块组织 │ │ ├── login.yaml │ │ └── logout.yaml │ ├── cart/ │ │ ├── add-item.yaml │ │ └── checkout.yaml │ └── global-setup.yaml # 全局设置如登录 ├── fixtures/ # 测试夹具和数据 │ └── test-users.json ├── __tests__/ # Jest测试文件用于驱动场景执行 │ └── smoke.test.ts └── utils/ └── scenario-loader.ts # 场景加载和变量注入工具一个典型的场景文件login.yaml如下所示name: “用户登录场景” description: “验证用户可以使用正确凭据登录系统” variables: # 场景级变量可被步骤引用 default_username: “standard_user” default_password: “secret_sauce” base_url: “https://www.saucedemo.com” scenes: - name: “导航到登录页” steps: - action: navigateTo params: url: “{{base_url}}” - name: “输入用户名和密码” steps: - action: fill params: selector: “#user-name” value: “{{default_username}}” - action: fill params: selector: “#password” value: “{{default_password}}” - name: “点击登录按钮并验证跳转” steps: - action: click params: selector: “#login-button” - action: assertText params: selector: “.title” expectedText: “Products”这种YAML格式非常直观非技术人员也能看懂大概流程。变量系统让数据与流程分离便于在不同环境测试/预发/生产切换。3.4 编写Jest测试驱动文件最后我们需要一个Jest测试文件来加载场景并驱动执行器运行。在__tests__/smoke.test.ts中import { PlaywrightExecutor } from ‘../src/executor/playwright-executor’; import { loadScenario } from ‘../utils/scenario-loader’; import path from ‘path’; describe(‘业务冒烟测试’, () { let executor: PlaywrightExecutor; beforeAll(async () { executor new PlaywrightExecutor(); await executor.initialize({ headless: true }); // CI环境用无头 }); afterAll(async () { await executor.cleanup(); }); // 动态加载scenarios目录下所有YAML文件生成测试用例 const scenarioFiles [ ‘../scenarios/auth/login.yaml’, ‘../scenarios/cart/add-item.yaml’, // … 更多文件 ]; scenarioFiles.forEach((filePath) { const scenario loadScenario(path.join(__dirname, filePath)); // 自定义加载函数 it(场景: ${scenario.name}, async () { for (const scene of scenario.scenes) { console.log(执行场景片段: ${scene.name}); for (const step of scene.steps) { const result await executor.executeStep(step, scenario.variables); if (!result.success) { throw new Error(步骤失败: ${result.message}); } } } }); }); });这样每当我们运行pnpm test或npm test时Jest就会自动遍历所有场景文件为每个文件生成一个独立的测试用例并用我们的PlaywrightExecutor去执行其中定义的每一个步骤。测试报告会清晰地显示每个场景的成功与否。4. 效率提升的关键AI辅助与智能维护架构搭好了脚本也能跑了但这只是开始。真正实现75%的效率提升靠的是后续的“智能”操作。我们主要在两个方向上引入了AI辅助。4.1 基于AI的初始场景生成手动编写YAML场景文件虽然比写代码简单但对于大型项目从零开始依然耗时。我们开发了一个内部的CLI工具generate-scenario。这个工具的工作原理是读取产品需求文档Markdown格式的特定章节或用户故事描述。通过Prompt Engineering构造一个清晰的提示词给大模型如GPT-4“请将以下用户故事转化为Midscene.js测试场景YAML格式包含必要的步骤如导航、输入、点击、断言。只输出YAML。”解析AI返回的YAML内容进行基本的语法和结构校验。将生成的YAML文件保存到对应的scenarios目录下。示例Prompt:你是一个资深的测试工程师。请将下面的用户故事转换成一个结构化的Midscene.js测试场景YAML文件。 用户故事作为一个已登录用户我想在搜索框输入商品名称然后看到相关的商品列表并且可以点击第一个商品进入详情页。 要求 1. 使用YAML格式。 2. 场景名和步骤名要清晰。 3. 使用合理的CSS选择器占位符如 #search-input。 4. 包含必要的断言步骤。 5. 假设基础URL是 “https://shop.example.com”。AI可能会生成如下内容经过人工微调后name: “用户搜索商品并查看详情” variables: base_url: “https://shop.example.com” search_keyword: “无线耳机” scenes: - name: “导航到首页并定位搜索框” steps: - action: navigateTo params: url: “{{base_url}}” - action: assertText params: selector: “h1.logo” expectedText: “Example Shop” - name: “输入搜索关键词并提交” steps: - action: fill params: selector: “input.search-box” value: “{{search_keyword}}” - action: click params: selector: “button.search-button” - name: “验证搜索结果并进入首个商品详情” steps: - action: assertText params: selector: “.search-result-header” expectedText: “包含‘无线耳机’的结果” - action: click params: selector: “.product-list div:first-child a” - action: assertText params: selector: “.product-title” expectedText: “*无线耳机*” # 使用通配符断言这个生成的内容已经具备了很好的骨架测试工程师只需要补充或修正具体的元素选择器以及调整一些细节逻辑即可。这将场景设计阶段的效率提升了超过50%。4.2 智能定位器维护与失败分析自动化测试脚本最大的维护成本来自于前端页面的变化导致的元素定位失败。我们基于Playwright的test.step和截图功能结合AI构建了一个“智能失败分析”流水线。失败捕获与上下文收集当某个步骤如click(‘#old-button’)失败时我们的执行器不会立即让整个测试用例失败。而是会捕获当前页面的截图和HTML快照。记录失败的选择器 (#old-button) 和试图执行的操作 (click)。将当前页面的部分DOM结构失败元素附近提取出来。AI辅助分析将这些信息旧选择器、操作类型、当前DOM片段发送给一个AI服务可以是OpenAI API也可以是内部微调的模型。Prompt如下前端页面可能发生了变化。原本想使用选择器 #old-button 来点击一个按钮但现在找不到这个元素了。 以下是当前页面相关区域的HTML代码片段提交订单取消请分析如果原来的 #old-button 对应的是“提交订单”按钮现在最稳定、最推荐的新选择器是什么请给出理由。建议与半自动修复AI可能会返回建议“建议使用># 安装依赖并运行测试 pnpm install pnpm exec playwright install --with-deps pnpm test:e2e # 对应运行Jest执行Midscene场景的脚本测试分组我们给场景打标签如smoke、regression、slow。在PR流水线中只运行smoke标签的快速场景5分钟内完成。在夜间构建中则运行全部regression场景。5.2 测试报告与质量门禁我们使用Jest的JUnit格式报告和Playwright的HTML报告并结合Allure生成丰富的测试报告。报告生成# 在package.json中配置 “scripts”: {“test:e2e”: “jesttests/ –configjest.e2e.config.js –reportersdefault –reportersjest-junit”, “report:generate”: “allure generate ./allure-results --clean -o ./allure-report”, “report:open”: “allure open ./allure-report” } jest-junit会在./test-results目录下生成XML报告供Jenkins、GitLab CI等工具解析。质量门禁在PR环节设置门禁如果smoke测试套件有任何失败则阻止代码合并。这确保了主干代码的核心功能始终是正常的。在发布环节每日回归测试的结果会生成趋势图。如果某个模块的失败率连续上升会自动创建Jira工单分配给对应的前端和测试负责人驱动他们及时修复脚本或产品缺陷。测试数据管理我们利用Playwright的storageState功能将登录等前置操作的状态保存为文件。在运行一系列需要登录的场景时首先运行一个“全局设置”场景完成登录并保存状态后续场景直接加载该状态避免了每个场景都重复登录大幅缩短执行时间。5.3 性能监控与优化效率提升不仅体现在编写和维护也体现在执行速度。我们监控每个场景的执行时间并对耗时超过阈值的场景进行分析优化。常见的优化手段包括并行执行利用Jest的–maxWorkers或Playwright的多个Browser Context并行执行独立的测试场景。前提是场景之间没有状态依赖。API Mock对于某些依赖外部慢接口的步骤使用Playwright的page.route()功能拦截请求返回预置的静态数据避免因后端响应慢而阻塞UI测试。减少不必要的等待检查场景步骤将固定的page.waitForTimeout(3000)替换为更智能的等待如waitForSelector或waitForLoadState(‘networkidle’)。通过以上工程化措施我们将自动化测试从“可选项”变成了开发流程中不可或缺的、自动化的质量守门员。测试执行从原来手动触发、需要半天才能跑完到现在每次提交后10分钟内即可得到反馈这才是效率提升最直观的体现。6. 常见问题、踩坑记录与排查指南在实际落地过程中我们遇到了不少问题。这里总结一份“避坑指南”希望能帮你少走弯路。6.1 元素定位与等待策略这是UI自动化最常见的问题域。问题1元素定位器不稳定经常因前端微调而失效。解决方案优先使用面向测试的属性。与开发团队约定为关键交互元素添加>现象可能原因排查步骤Target closed错误页面或浏览器在操作前意外关闭检查步骤逻辑确保在page可用后才调用操作检查是否有未处理的异常导致提前清理。Timeout错误元素未在指定时间内出现/可交互1. 检查选择器是否正确。2. 检查页面是否真的加载完成等待网络空闲。3. 检查是否有弹窗、遮罩层挡住了目标元素。断言失败但页面看起来正常断言时机不对页面尚未更新在断言前增加适当的等待如await page.waitForLoadState(‘networkidle’)或等待某个特定元素出现。在CI上通过本地失败或反之环境差异时区、分辨率、数据统一使用Docker容器运行测试使用固定的测试数据在CI配置中设置明确的环境变量如TZUTC。最后分享一个我们踩过的大坑早期我们为了图省事在步骤仓库的函数里大量使用了page.$eval和page.$$eval来执行自定义JS。这确实灵活但破坏了Playwright内置的自动等待机制导致时序问题极难调试。后来我们统一了原则所有与元素的交互只要Playwright原生API能实现的绝不用$eval。只有获取复杂属性或执行特殊滚动时才考虑使用并且要手动加上等待。这个原则让测试稳定性直接上了一个等级。