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的设计哲学与当前火热的MCP(Model 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接下来,配置TypeScript(tsconfig.json)和Jest(jest.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: Map<string, Function> = new Map(); constructor() { this.registerCoreSteps(); } // 1. 初始化浏览器环境 async initialize(config?: any): Promise<void> { 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): Promise<StepResult> { 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): Promise<StepResult> { 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): Promise<StepResult> { 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): Promise<StepResult> { 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): Promise<StepResult> { 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(): Promise<void> { 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场景。 - 报告生成:
# 在package.json中配置 “scripts”: { 质量门禁:
- 在PR环节:设置门禁,如果
@smoke测试套件有任何失败,则阻止代码合并。这确保了主干代码的核心功能始终是正常的。 - 在发布环节:每日回归测试的结果会生成趋势图。如果某个模块的失败率连续上升,会自动创建Jira工单分配给对应的前端和测试负责人,驱动他们及时修复脚本或产品缺陷。
- 在PR环节:设置门禁,如果
测试数据管理:我们利用Playwright的
storageState功能,将登录等前置操作的状态保存为文件。在运行一系列需要登录的场景时,首先运行一个“全局设置”场景完成登录并保存状态,后续场景直接加载该状态,避免了每个场景都重复登录,大幅缩短执行时间。- 并行执行:利用Jest的
–maxWorkers或Playwright的多个Browser Context,并行执行独立的测试场景。前提是场景之间没有状态依赖。 - API Mock:对于某些依赖外部慢接口的步骤,使用Playwright的
page.route()功能拦截请求,返回预置的静态数据,避免因后端响应慢而阻塞UI测试。 - 减少不必要的等待:检查场景步骤,将固定的
page.waitForTimeout(3000)替换为更智能的等待,如waitForSelector或waitForLoadState(‘networkidle’)。 问题1:元素定位器不稳定,经常因前端微调而失效。
- 解决方案:优先使用面向测试的属性。与开发团队约定,为关键交互元素添加
>现象可能原因 排查步骤 Target closed错误页面或浏览器在操作前意外关闭 检查步骤逻辑,确保在 page可用后才调用操作;检查是否有未处理的异常导致提前清理。Timeout错误元素未在指定时间内出现/可交互 1. 检查选择器是否正确。2. 检查页面是否真的加载完成(等待网络空闲)。3. 检查是否有弹窗、遮罩层挡住了目标元素。 断言失败,但页面看起来正常 断言时机不对,页面尚未更新 在断言前增加适当的等待,如 await page.waitForLoadState(‘networkidle’)或等待某个特定元素出现。在CI上通过,本地失败(或反之) 环境差异(时区、分辨率、数据) 统一使用Docker容器运行测试;使用固定的测试数据;在CI配置中设置明确的环境变量(如 TZ=UTC)。最后,分享一个我们踩过的大坑:早期我们为了图省事,在步骤仓库的函数里大量使用了
page.$eval和page.$$eval来执行自定义JS。这确实灵活,但破坏了Playwright内置的自动等待机制,导致时序问题极难调试。后来我们统一了原则:所有与元素的交互,只要Playwright原生API能实现的,绝不用$eval。只有获取复杂属性或执行特殊滚动时才考虑使用,并且要手动加上等待。这个原则让测试稳定性直接上了一个等级。
- 解决方案:优先使用面向测试的属性。与开发团队约定,为关键交互元素添加
5.2 测试报告与质量门禁
我们使用Jest的JUnit格式报告和Playwright的HTML报告,并结合Allure生成丰富的测试报告。
“test:e2e”: “jesttests/ –config=jest.e2e.config.js –reporters=default –reporters=jest-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等工具解析。
5.3 性能监控与优化
效率提升不仅体现在编写和维护,也体现在执行速度。我们监控每个场景的执行时间,并对耗时超过阈值的场景进行分析优化。常见的优化手段包括:
通过以上工程化措施,我们将自动化测试从“可选项”变成了开发流程中不可或缺的、自动化的质量守门员。测试执行从原来手动触发、需要半天才能跑完,到现在每次提交后10分钟内即可得到反馈,这才是效率提升最直观的体现。
6. 常见问题、踩坑记录与排查指南
在实际落地过程中,我们遇到了不少问题。这里总结一份“避坑指南”,希望能帮你少走弯路。
6.1 元素定位与等待策略
这是UI自动化最常见的问题域。
