Playwright-CLI与Skills结合:实现UI自动化测试的意图驱动与模块化实践

📅 2026/6/22 8:14:24
Playwright-CLI与Skills结合:实现UI自动化测试的意图驱动与模块化实践
1. 项目概述当Playwright-CLI遇上SkillsUI自动化测试的“智能进化”最近在搞UI自动化测试的朋友估计都听过Playwright的大名。它确实是个好工具跨浏览器、速度快、API设计得也优雅。但说实话写和维护一堆测试脚本尤其是处理那些动态加载、状态复杂、甚至带点AI交互的现代Web应用时还是有点头疼。脚本一多维护成本就上来了页面一变就得满世界找选择器想搞点高级断言或者复杂流程代码量蹭蹭往上涨。这不最近社区里冒出来一个挺有意思的组合Playwright-CLI加上Skills。乍一听你可能觉得Playwright-CLI不就是个命令行工具吗Skills又是什么新概念这俩放一块儿能玩出什么花来我花了一段时间深入研究并实践了这个组合发现它远不止是“命令行插件”那么简单更像是在给传统的UI自动化测试框架注入“智能”和“意图驱动”的能力让测试脚本的编写和维护体验发生了质的变化。简单来说它让测试从“告诉机器每一步怎么点”的微观指令升级到了“告诉机器我想测什么”的宏观意图描述。这个实战案例就是想跟你聊聊我是怎么用这个组合把一个中等复杂度的电商后台管理系统的UI自动化测试给搞定的。过程中踩了不少坑也总结了不少能直接“抄作业”的经验。无论你是正在为UI测试维护成本发愁的测试工程师还是想探索下一代自动化测试可能性的开发者相信这篇分享都能给你带来一些实实在在的启发。2. 核心思路拆解从“脚本驱动”到“意图驱动”的范式转变在深入代码之前我们必须先理解这套组合拳背后的核心思想。传统的UI自动化无论是用Selenium还是Playwright的常规模式我们本质上是在编写一个非常详细的“操作说明书”打开某个URL找到ID为submit-btn的按钮点击它然后检查某个元素里的文本是不是“成功”。这种模式我称之为“脚本驱动”。2.1 Playwright-CLI不只是命令行更是交互式探索器很多人把Playwright-CLI简单理解为用来生成代码的工具比如playwright codegen。这没错但它更是一个强大的交互式探索和诊断工具。你可以用它快速启动浏览器执行单条命令来检查页面状态、模拟操作而无需编写完整的脚本。这对于快速验证某个操作是否可行、某个选择器是否稳定提供了极大的便利。在这个组合中CLI扮演了“侦察兵”和“快速验证器”的角色。2.2 Skills赋予CLI“超能力”的模块化插件Skills才是这个组合里的“灵魂”。你可以把它理解为一系列封装了特定领域知识或复杂操作逻辑的插件Plugin或技能包。一个Skill可以很简单比如“登录到测试环境”也可以很复杂比如“验证这个数据表格的排序、分页和过滤功能是否正常”。Skills的核心价值在于抽象与封装将常用的、复杂的操作序列如登录、导航到特定模块、准备测试数据封装成一个可复用的“技能”。测试用例中只需要调用useSkill(‘login’)而不用关心具体的URL、用户名输入框ID和密码。意图表达测试用例的编写变得更接近自然语言描述。比如与其写十行代码去操作一个日期选择器不如调用一个selectDateRange的Skill传入开始和结束日期参数。测试逻辑的焦点从“如何做”转移到了“做什么”。维护性提升当登录页面的HTML结构改了你只需要更新login这个Skill内部的实现所有引用了该Skill的测试用例都自动生效维护点高度集中。2.3 组合威力CLI Skills 的工作流在实际工作中这个组合催生了一种新的高效工作流探索与定义使用CLI面对一个新页面或新功能先用Playwright-CLI进行交互式探索。快速尝试点击、输入、获取选择器验证交互逻辑。这个阶段的目标是理解页面并构思需要封装成Skill的“原子操作”或“组合操作”。技能开发编写Skills将探索确认的、稳定的、可复用的操作逻辑编写成Skills。每个Skill应该职责单一接口清晰。例如Skill: fillAddressForm接收姓名、电话、地址等参数内部处理所有表单字段的填充和验证。用例编写使用Skills在正式的测试脚本中大量使用你开发好的Skills来构建测试用例。你的测试脚本会变得非常简洁、易读更像是一份高级别的测试规格说明书。调试与维护CLI Skills当某个测试失败时你可以用CLI快速单独运行对应的Skill或者模拟故障场景定位问题是出在Skill内部实现还是页面本身发生了变化。Skill的模块化使得调试范围大大缩小。注意这里的“Skills”是一个广义概念。它可能指的是Playwright社区中一些类似“插件”的实践模式也可能是某些基于Playwright的二次封装框架如playwright-skill库提供的机制甚至是你们团队内部约定的一套工具函数库的命名。其核心思想是“模块化”和“意图化”具体实现形式可以灵活。3. 实战环境搭建与核心工具选型理论说再多不如动手干。我们以一个虚拟的“星辰电商后台管理系统”作为测试对象来搭建我们的实战环境。3.1 基础环境准备首先确保你的机器上已经安装了Node.js建议LTS版本和npm或yarn。然后初始化项目并安装Playwright核心包。# 创建一个新的项目目录 mkdir playwright-skills-demo cd playwright-skills-demo # 初始化npm项目 npm init -y # 安装Playwright npm install playwright/test # 安装Playwright支持的浏览器Chromium, Firefox, WebKit npx playwright install3.2 构建Skills体系结构这是最关键的一步。我们不会依赖某个特定的、尚未广泛稳定的“Skills框架”而是采用一种务实、灵活的方式在项目中创建一个skills目录将每个Skill实现为一个独立的JavaScript/TypeScript模块。项目结构规划如下playwright-skills-demo/ ├── package.json ├── playwright.config.ts # Playwright配置文件 ├── tests/ # 存放测试用例 │ └── admin-order.spec.ts ├── skills/ # 核心Skills技能库目录 │ ├── index.ts # Skills统一出口 │ ├── auth.skill.ts # 认证相关Skill │ ├── navigation.skill.ts # 导航相关Skill │ ├──>// skills/auth.skill.ts import { Page } from playwright/test; /** * 登录到星辰电商后台管理系统 * param {Page} page - Playwright页面对象 * param {Object} credentials - 登录凭据 * param {string} [credentials.usernameadmin] - 用户名 * param {string} [credentials.passwordadmin123] - 密码 * returns {Promisevoid} */ export async function login( page: Page, credentials: { username?: string; password?: string } {} ): Promisevoid { const { username admin, password admin123 } credentials; // 1. 导航到登录页 // 注意这里使用相对路径或配置的基础URL提高可移植性 await page.goto(/login); // 假设在playwright.config.ts中配置了baseURL // 2. 等待登录表单加载完成 // 使用更稳定的选择器策略根据文本内容定位或者使用data-testid属性如果开发配合添加了 await page.waitForSelector(form:has-text(用户登录)); // 3. 填充用户名和密码 // 优先使用有明确语义的selector如 input[nameusername] await page.fill(input[nameusername], username); await page.fill(input[namepassword], password); // 4. 点击登录按钮 // 避免使用 .click(‘button’) 这种模糊选择可能点到其他按钮 await page.click(button[typesubmit]); // 5. 等待登录成功后的页面跳转或元素出现 // 这是断言的一部分确保登录确实成功了 await page.waitForURL(**/dashboard); // 等待跳转到仪表盘 // 同时等待一个只有登录后才有的元素出现双重验证 await page.waitForSelector(.user-avatar); console.log(用户 ${username} 登录成功。); } /** * 登出系统 * param {Page} page - Playwright页面对象 */ export async function logout(page: Page): Promisevoid { // 点击用户头像下拉菜单 await page.click(.user-avatar); // 等待登出菜单项出现并点击 await page.click(text退出登录); // 等待跳转回登录页 await page.waitForURL(**/login); console.log(用户已登出。); }这个Skill的几点设计思考参数默认值提供了默认的测试账号使得在大部分测试中调用login(page)即可简化调用。需要特定账号时再传入参数。稳健的选择器优先使用name属性或>// skills/navigation.skill.ts import { Page } from playwright/test; // 定义系统的导航结构可以单独放在一个配置文件中 const NAV_MAP { 订单管理: { path: /order, subMenu: { 订单列表: /order/list, 退款管理: /order/refund, } }, 商品管理: { path: /product, subMenu: { 商品列表: /product/list, 添加商品: /product/add, } }, // ... 其他菜单 }; /** * 导航到指定的一级和二级菜单页面 * param {Page} page * param {string} mainMenu - 一级菜单名称如‘订单管理’ * param {string} [subMenu] - 二级菜单名称如‘订单列表’ * returns {Promisevoid} */ export async function navigateTo( page: Page, mainMenu: string, subMenu?: string ): Promisevoid { // 1. 确保当前页面已经加载了导航菜单通常在登录后的页面 // 如果不在仪表盘可以先导航到dashboard if (!page.url().includes(/dashboard)) { await page.goto(/dashboard); await page.waitForSelector(.sidebar-nav); } // 2. 点击一级菜单 // 使用精确文本匹配避免点到包含相同文字的其它元素 const mainMenuLocator page.locator(.sidebar-nav).getByText(mainMenu, { exact: true }); await mainMenuLocator.click(); // 3. 等待一级菜单展开如果有动画 await page.waitForTimeout(300); // 少量硬等待用于CSS展开动画酌情使用 // 4. 如果指定了二级菜单则点击 if (subMenu) { // 二级菜单通常在展开的一级菜单下方 // 使用更精确的选择器找到已展开的一级菜单项下的子菜单项 const subMenuLocator mainMenuLocator.locator(..) // 定位到父元素li .locator(.submenu) .getByText(subMenu, { exact: true }); await subMenuLocator.click(); // 5. 等待导航完成根据NAV_MAP验证URL const expectedPath NAV_MAP[mainMenu]?.subMenu?.[subMenu] || NAV_MAP[mainMenu]?.path; if (expectedPath) { await page.waitForURL(**${expectedPath}); } } else { // 只点击一级菜单等待导航到其默认页面 const expectedPath NAV_MAP[mainMenu]?.path; if (expectedPath) { await page.waitForURL(**${expectedPath}); } } console.log(导航到: ${mainMenu}${subMenu ? - subMenu : }); }避坑指南导航菜单菜单状态点击菜单前需要确保菜单容器如.sidebar-nav已加载。有时菜单是动态渲染的需要waitForSelector。文本匹配使用{ exact: true }选项进行精确文本匹配非常重要。如果页面上有“订单管理”和“订单管理新”不精确匹配会导致点击错误。动画等待菜单展开/收起常有CSS过渡动画。直接点击子菜单可能因为动画未完成而失败。适当的waitForTimeout(300)是务实的选择虽然我们通常提倡避免硬等待但处理UI动画时少量、固定的硬等待有时比复杂的轮询逻辑更可靠。URL验证导航后一定要用waitForURL等待目标页面加载完成这是测试稳定性的基石。4.2 数据表格操作Skill (data-grid.skill.ts)处理分页、排序、过滤后台系统最多的就是表格。这个Skill会复杂一些但一旦封装好威力巨大。// skills/data-grid.skill.ts import { Page, Locator } from playwright/test; /** * 获取数据表格当前页的所有行数据 * param {Page} page * param {string} tableSelector - 表格的选择器 * returns {PromiseLocator} 所有行元素的Locator */ export async function getTableRows(page: Page, tableSelector: string .ant-table-tbody): PromiseLocator { await page.waitForSelector(${tableSelector} tr); return page.locator(${tableSelector} tr); } /** * 对表格指定列进行排序点击表头 * param {Page} page * param {string} columnHeaderText - 表头列文本 * param {‘asc’ | ‘desc’} [order] - 排序顺序不传则点击切换 */ export async function sortTableByColumn(page: Page, columnHeaderText: string, order?: asc | desc): Promisevoid { const header page.locator(thead th:has-text(${columnHeaderText})); await header.waitFor({ state: visible }); const currentSort await header.getAttribute(aria-sort); // 假设使用aria-sort属性 if (order) { // 如果指定了顺序且当前顺序不是目标顺序则点击 if (currentSort ! order) { await header.click(); // 点击后可能需要再次点击以达到目标顺序取决于组件实现 if ((await header.getAttribute(aria-sort)) ! order) { await header.click(); } } } else { // 不指定顺序单纯点击切换 await header.click(); } // 等待表格数据重新加载 await page.waitForLoadState(networkidle); // 等待网络请求安静下来 await page.waitForTimeout(500); // 再给表格渲染一点时间 console.log(表格已按列“${columnHeaderText}”${order ? 进行${order}排序 : 切换排序}。); } /** * 在表格的全局搜索框输入关键词过滤 * param {Page} page * param {string} keyword - 搜索关键词 * param {string} [searchInputSelector.search-input] - 搜索框选择器 */ export async function filterTableByKeyword( page: Page, keyword: string, searchInputSelector: string .search-input ): Promisevoid { const searchInput page.locator(searchInputSelector); await searchInput.waitFor({ state: visible }); // 清空原有内容模拟CtrlA然后Delete比fill(‘’)更接近真实操作 await searchInput.click(); await page.keyboard.press(ControlA); await page.keyboard.press(Delete); // 输入新关键词 await searchInput.fill(keyword); // 触发搜索可能是按回车也可能是点击搜索按钮 await page.keyboard.press(Enter); // 或者如果有搜索按钮 await page.click(‘.search-btn’); // 等待过滤结果 await page.waitForLoadState(networkidle); await page.waitForSelector(${tableSelector} tr, { timeout: 10000 }); // 等待至少有一行数据或空状态 console.log(表格已按关键词“${keyword}”过滤。); } /** * 验证表格当前页的数据行数 * param {Page} page * param {number} expectedCount - 期望的行数 * param {string} [tableSelector] */ export async function verifyTableRowCount( page: Page, expectedCount: number, tableSelector: string .ant-table-tbody ): Promisevoid { const rows await getTableRows(page, tableSelector); const actualCount await rows.count(); if (actualCount ! expectedCount) { throw new Error(表格行数验证失败。期望: ${expectedCount}, 实际: ${actualCount}); } console.log(表格行数验证通过共 ${actualCount} 行。); }避坑指南数据表格异步加载表格数据几乎都是异步加载的。任何操作排序、过滤、翻页后必须等待数据重新加载完成。waitForLoadState(‘networkidle’)结合waitForSelector等待行元素出现是标准做法。状态判断排序按钮的状态升序、降序、无排序通常通过CSS类或aria-*属性体现。与你的前端开发团队约定好这些状态标识可以使你的测试更健壮。空状态处理过滤后可能没有数据。你的waitForSelector需要能处理这种情况可以设置timeout并检查是否出现了“暂无数据”的提示元素而不是一直等待tr出现。选择器通用性这个Skill示例基于类似Ant Design的类名.ant-table-tbody。在实际项目中你需要根据你们实际使用的UI组件库如Element UI, Vuetify等调整选择器。更好的做法是让开发同学为测试关键元素添加>// skills/index.ts export { login, logout } from ./auth.skill; export { navigateTo } from ./navigation.skill; export { getTableRows, sortTableByColumn, filterTableByKeyword, verifyTableRowCount } from ./data-grid.skill; // 后续添加的Skill也从这里导出5. 编写基于Skills的测试用例有了强大的Skills武器库编写测试用例就变成了一种愉悦的、声明式的体验。我们以测试“订单列表”页面为例。// tests/admin-order.spec.ts import { test, expect } from playwright/test; // 从skills库中导入我们需要的“超能力” import { login, navigateTo } from ../skills; import { filterTableByKeyword, verifyTableRowCount, sortTableByColumn } from ../skills; // 使用测试级别的Hook在每个测试开始前登录并导航到订单列表 test.beforeEach(async ({ page }) { // 使用login Skill简洁明了 await login(page); // 使用navigateTo Skill语义清晰 await navigateTo(page, 订单管理, 订单列表); }); test(订单列表 - 默认加载并验证关键列, async ({ page }) { // 验证页面标题 await expect(page.locator(h1)).toHaveText(订单列表); // 验证表格关键列头存在 await expect(page.locator(thead)).toContainText([订单号, 用户名, 订单金额, 状态, 创建时间]); // 使用Skill验证表格至少有一行数据非空 const rows await page.locator(.ant-table-tbody tr); await expect(rows).toHaveCountGreaterThan(0); // 也可以使用更具体的Skill如果需要复用逻辑 // await verifyTableRowCount(page, (count) count 0); // 可以扩展Skill支持回调 }); test(订单列表 - 按订单号过滤, async ({ page }) { const testOrderId TEST202404280001; // 使用filterTableByKeyword Skill进行过滤 await filterTableByKeyword(page, testOrderId, .search-input[placeholder搜索订单号/用户名]); // 过滤后验证表格中只包含包含该关键词的行 const rows page.locator(.ant-table-tbody tr); const count await rows.count(); for (let i 0; i count; i) { const rowText await rows.nth(i).textContent(); expect(rowText).toContain(testOrderId); } console.log(过滤验证通过所有行均包含订单号: ${testOrderId}); }); test(订单列表 - 按订单金额降序排序, async ({ page }) { // 使用sortTableByColumn Skill进行排序 await sortTableByColumn(page, 订单金额, desc); // 获取排序后的金额列数据假设金额在第三列索引为2 const amountCells page.locator(.ant-table-tbody tr td:nth-child(3)); const count await amountCells.count(); let prevAmount Number.POSITIVE_INFINITY; for (let i 0; i count; i) { const cellText await amountCells.nth(i).textContent(); // 清理货币符号和千分位逗号例如 “¥1,234.56” - 1234.56 const currentAmount parseFloat(cellText.replace(/[^0-9.-]/g, )); // 验证降序当前金额应小于等于前一个金额 expect(currentAmount).toBeLessThanOrEqual(prevAmount); prevAmount currentAmount; } console.log(订单金额降序排序验证通过。); }); test(订单列表 - 综合场景过滤后排序, async ({ page }) { // 这是一个更复杂的场景串联使用多个Skills // 1. 先过滤出“已完成”的订单 await filterTableByKeyword(page, 已完成, .status-filter-select); // 假设状态过滤是个选择框 // 2. 验证过滤后的行数 await verifyTableRowCount(page, 5); // 假设我们知道有5个已完成订单 // 3. 再按创建时间降序排列查看最新的订单 await sortTableByColumn(page, 创建时间, desc); // 4. 断言第一行订单的状态是“已完成”并且创建时间是最新的可以通过比较时间字符串 const firstRowStatus page.locator(.ant-table-tbody tr:nth-child(1) td:nth-child(4)); // 状态列 await expect(firstRowStatus).toHaveText(已完成); console.log(综合场景过滤排序测试通过。); });测试用例的设计心得用例清晰每个测试用例聚焦一个具体的功能点过滤、排序、综合操作。用例标题清晰地描述了测试意图。高度可读通篇看不到复杂的page.locator(‘...’)链式调用和冗长的等待逻辑。取而代之的是login(page),filterTableByKeyword(...)这样具有业务语义的函数调用。任何阅读代码的人包括产品经理或新同事都能快速理解这个测试在做什么。维护成本低如果“订单列表”页面的搜索框选择器从.search-input变成了.query-input你只需要去修改>// config/test-config.ts export const TestConfig { baseURL: process.env.BASE_URL || http://localhost:3000, adminUser: { username: process.env.ADMIN_USER || admin, password: process.env.ADMIN_PASS || admin123 }, // 可以定义不同角色的用户 testUser: { username: test_user, password: test123 } }; // 在playwright.config.ts中设置baseURL // import { TestConfig } from ‘./config/test-config’; // ... // use: { baseURL: TestConfig.baseURL, ... } // 修改login Skill支持从配置读取 export async function login( page: Page, userType: admin | test admin ): Promisevoid { const credentials userType admin ? TestConfig.adminUser : TestConfig.testUser; // ... 其余登录逻辑不变 }6.2 错误处理与重试机制为Skills添加健壮的错误处理和自动重试提高测试在非稳定环境如测试环境偶尔抖动下的通过率。// utils/retry.ts export async function retryOperationT( operation: () PromiseT, maxAttempts: number 3, delayMs: number 1000 ): PromiseT { let lastError: Error; for (let attempt 1; attempt maxAttempts; attempt) { try { return await operation(); } catch (error) { lastError error as Error; console.warn(操作第 ${attempt} 次尝试失败:, error.message); if (attempt maxAttempts) { console.log(等待 ${delayMs}ms 后重试...); await new Promise(resolve setTimeout(resolve, delayMs)); } } } throw new Error(操作在 ${maxAttempts} 次尝试后均失败。最后错误: ${lastError.message}); } // 在Skill中使用例如在navigateTo中点击菜单可能因动画失败 export async function navigateToWithRetry(...) { // ... await retryOperation(async () { await mainMenuLocator.click({ timeout: 5000 }); // 单次操作设置较短超时 // 添加一些验证确保点击成功例如检查菜单是否展开 await expect(mainMenuLocator.locator(..)).toHaveClass(/active/); // 假设激活状态有active类 }, 3, 500); // ... }6.3 截图与日志记录Skill封装一个通用的失败截图和日志记录Skill在测试出现问题时自动调用方便排查。// skills/reporting.skill.ts import { Page } from playwright/test; import * as path from path; /** * 在测试失败时自动截图并记录页面状态 * param {Page} page * param {string} testName - 测试用例名称 * param {string} [stepDescription] - 失败时的步骤描述 */ export async function captureOnFailure( page: Page, testName: string, stepDescription?: string ): Promisevoid { // 生成时间戳和文件名 const timestamp new Date().toISOString().replace(/[:.]/g, -); const safeTestName testName.replace(/[^a-z0-9]/gi, _).toLowerCase(); const fileName failure_${safeTestName}_${timestamp}.png; const screenshotPath path.join(test-results, screenshots, fileName); // 截图 await page.screenshot({ path: screenshotPath, fullPage: true }); console.error([FAILURE CAPTURED] 测试“${testName}”在步骤“${stepDescription || 未知}”失败。截图已保存至: ${screenshotPath}); // 可选记录当前页面的HTML或控制台日志需要更复杂的配置 // const html await page.content(); // fs.writeFileSync(path.join(test-results, html, ${fileName}.html), html); }然后你可以在Playwright的test.afterEach钩子中根据测试结果调用这个Skill。7. 常见问题排查与实战心得7.1 元素定位器Selector不稳定经常失效这是UI自动化最常见的问题。首要策略与开发协作添加测试专用属性。如>