Playwright扩展开发:自定义插件与工具创建
关注 霍格沃兹测试学院公众号,回复「资料」, 领取人工智能测试开发技术合集
在自动化测试的实践中,我们经常会遇到重复性的任务和特定的业务需求,而Playwright的原生功能并不总能完全满足这些需求。这时候,开发自定义插件和工具就显得尤为重要。本文将带你深入探索如何为Playwright创建功能强大的扩展。
为什么要开发自定义插件?
在我多年的测试自动化经验中,我发现团队经常会遇到这些情况:
-
重复代码片段在不同测试文件中频繁出现 -
特定业务逻辑需要封装成可重用组件 -
第三方服务集成需要统一处理 -
团队规范需要强制执行
自定义插件正是解决这些问题的利器。它们不仅能够提高代码复用性,还能让测试代码更加简洁、可维护。
环境准备与基础架构
开始之前,确保你已经安装了最新版本的Playwright:
npm install playwright# 或pip install playwright
让我们先从一个简单的目录结构开始:
playwright-extensions/├── package.json├── src/│ ├── fixtures/│ ├── reporters/│ ├── utilities/│ └── plugins/└── tests/
创建第一个自定义Fixture
Fixtures是Playwright Test最强大的特性之一。让我们创建一个处理登录状态的自定义fixture。
JavaScript版本:
// src/fixtures/auth.fixture.jsconst { test: baseTest, expect } = require('@playwright/test');const fs = require('fs').promises;const path = require('path');classAuthManager{constructor(page, storageStatePath) {this.page = page;this.storageStatePath = storageStatePath; }async login(credentials = {}) {const { username = process.env.TEST_USER, password = process.env.TEST_PASS } = credentials;awaitthis.page.goto('/login');awaitthis.page.fill('#username', username);awaitthis.page.fill('#password', password);awaitthis.page.click('button[type="submit"]');// 等待登录成功await expect(this.page.locator('.user-profile')).toBeVisible();// 保存认证状态awaitthis.saveAuthState();returnthis.page; }async saveAuthState() {const storageState = awaitthis.page.context().storageState();await fs.writeFile(this.storageStatePath,JSON.stringify(storageState, null, 2) ); }async restoreAuthState() {try {const storageState = JSON.parse(await fs.readFile(this.storageStatePath, 'utf-8') );awaitthis.page.context().addCookies(storageState.cookies);awaitthis.page.context().addInitScript(storageState.origins); } catch (error) {console.log('No saved auth state found, proceeding with fresh session'); } }}// 扩展基础的test对象const test = baseTest.extend({auth: async ({ page }, use) => {const authManager = new AuthManager( page, path.join(__dirname, '../../.auth/session.json') );await authManager.restoreAuthState();await use(authManager);// 测试结束后可以在这里执行清理操作if (test.info().status === 'passed') {await authManager.saveAuthState(); } },authenticatedPage: async ({ auth, page }, use) => {if (!await page.locator('.user-profile').isVisible()) {await auth.login(); }await use(page); }});module.exports = { test, expect };
Python版本:
# src/fixtures/auth_fixture.pyimport jsonimport osfrom pathlib import Pathfrom typing import Optional, Dict, Anyimport pytestfrom playwright.sync_api import Page, BrowserContextclassAuthManager:def__init__(self, page: Page, storage_state_path: str): self.page = page self.storage_state_path = storage_state_pathdeflogin(self, credentials: Optional[Dict[str, str]] = None) -> Page: credentials = credentials or {} username = credentials.get('username') or os.getenv('TEST_USER') password = credentials.get('password') or os.getenv('TEST_PASS') self.page.goto('/login') self.page.fill('#username', username) self.page.fill('#password', password) self.page.click('button[type="submit"]')# 等待登录成功 self.page.wait_for_selector('.user-profile', state='visible')# 保存认证状态 self.save_auth_state()return self.pagedefsave_auth_state(self) -> None: storage_state = self.page.context.storage_state() Path(self.storage_state_path).parent.mkdir(parents=True, exist_ok=True)with open(self.storage_state_path, 'w') as f: json.dump(storage_state, f, indent=2)defrestore_auth_state(self) -> None:try:with open(self.storage_state_path, 'r') as f: storage_state = json.load(f) self.page.context.add_cookies(storage_state['cookies'])for origin in storage_state.get('origins', []):# 处理origins逻辑passexcept FileNotFoundError: print('No saved auth state found, proceeding with fresh session')@pytest.fixturedefauth(page: Page, request) -> AuthManager:"""提供认证管理的fixture""" test_dir = Path(request.node.fspath).parent storage_path = test_dir / '.auth' / 'session.json' auth_manager = AuthManager(page, str(storage_path)) auth_manager.restore_auth_state()yield auth_manager# 测试通过后保存状态if request.node.rep_call.passed: auth_manager.save_auth_state()@pytest.fixturedefauthenticated_page(auth: AuthManager, page: Page) -> Page:"""返回已认证的页面"""ifnot page.locator('.user-profile').is_visible(): auth.login()return page
开发自定义Reporter
当内置的报告器不能满足需求时,我们可以创建自定义的报告器。以下是一个集成Slack通知的报告器示例:
// src/reporters/slack-reporter.jsclassSlackReporter{constructor(options = {}) {this.webhookUrl = options.webhookUrl || process.env.SLACK_WEBHOOK_URL;this.channel = options.channel || '#test-reports';this.username = options.username || 'Playwright Bot'; } onBegin(config, suite) {console.log(`🚀 开始执行测试套件: ${suite.suites.length}个套件`);this.startTime = Date.now();this.totalTests = 0;this.passedTests = 0;this.failedTests = 0; } onTestBegin(test) {this.totalTests++; } onTestEnd(test, result) {if (result.status === 'passed') {this.passedTests++; } elseif (result.status === 'failed') {this.failedTests++;// 实时通知失败的测试this.sendImmediateAlert(test, result); } } onEnd(result) {const duration = ((Date.now() - this.startTime) / 1000).toFixed(2);const passRate = ((this.passedTests / this.totalTests) * 100).toFixed(1);this.sendSummaryReport({totalTests: this.totalTests,passedTests: this.passedTests,failedTests: this.failedTests,passRate: passRate,duration: duration,status: this.failedTests === 0 ? 'success' : 'failure' }); }async sendImmediateAlert(test, result) {if (!this.webhookUrl) return;const message = {channel: this.channel,username: this.username,attachments: [{color: 'danger',title: `❌ 测试失败: ${test.title}`,fields: [ {title: '文件',value: test.location.file,short: true }, {title: '执行时间',value: `${result.duration}ms`,short: true } ],text: `错误信息:\n\`\`\`${result.error?.message || 'Unknown error'}\`\`\``,footer: 'Playwright Test Runner',ts: Math.floor(Date.now() / 1000) }] };awaitthis.sendToSlack(message); }async sendSummaryReport(stats) {if (!this.webhookUrl) return;const message = {channel: this.channel,username: this.username,attachments: [{color: stats.status === 'success' ? 'good' : 'danger',title: `📊 测试执行完成`,fields: [ {title: '总测试数',value: stats.totalTests.toString(),short: true }, {title: '通过',value: stats.passedTests.toString(),short: true }, {title: '失败',value: stats.failedTests.toString(),short: true }, {title: '通过率',value: `${stats.passRate}%`,short: true }, {title: '执行时间',value: `${stats.duration}秒`,short: true } ],footer: 'Playwright Test Runner',ts: Math.floor(Date.now() / 1000) }] };awaitthis.sendToSlack(message); }async sendToSlack(message) {try {const response = await fetch(this.webhookUrl, {method: 'POST',headers: {'Content-Type': 'application/json', },body: JSON.stringify(message) });if (!response.ok) {console.error('发送Slack通知失败:', await response.text()); } } catch (error) {console.error('发送Slack通知时出错:', error); } }}module.exports = SlackReporter;
创建页面对象模型(POM)插件
对于大型项目,我们可以创建一个POM管理器来简化页面对象的使用:
// src/plugins/pom-manager.jsclassPOMManager{constructor(page) {this.page = page;this._components = newMap();this._initComponents(); } _initComponents() {// 自动注册components目录下的所有组件const components = require.context('./components', true, /\.js$/); components.keys().forEach(key => {const ComponentClass = components(key).default;const componentName = this._getComponentName(key);this.registerComponent(componentName, ComponentClass); }); } _getComponentName(filePath) {// 从文件路径提取组件名return filePath .split('/') .pop() .replace('.js', '') .replace(/([A-Z])/g, ' $1') .trim() .toLowerCase() .replace(/\s+/g, '-'); } registerComponent(name, ComponentClass) {this._components.set(name, ComponentClass); } getComponent(name, options = {}) {const ComponentClass = this._components.get(name);if (!ComponentClass) {thrownewError(`组件 "${name}" 未注册`); }returnnew ComponentClass({page: this.page, ...options }); }// 动态创建页面对象 createPageObject(PageClass) {returnnew PageClass(this.page); }}// 组件基类classBaseComponent{constructor({ page, rootSelector = '' }) {this.page = page;this.rootSelector = rootSelector; } selector(selector) {returnthis.rootSelector ? `${this.rootSelector}${selector}` : selector; }async waitForVisible(timeout = 30000) {awaitthis.page.waitForSelector(this.selector(':visible'), { timeout } ); }}// 使用示例:导航栏组件classNavigationBarextendsBaseComponent{constructor(options) {super({ ...options, rootSelector: '.nav-bar' }); }get homeLink() {returnthis.page.locator(this.selector('.home-link')); }get profileLink() {returnthis.page.locator(this.selector('.profile-link')); }async goToHome() {awaitthis.homeLink.click();awaitthis.page.waitForURL('**/dashboard'); }async goToProfile() {awaitthis.profileLink.click();awaitthis.page.waitForURL('**/profile'); }}module.exports = { POMManager, BaseComponent, NavigationBar };
Playwright mcp技术学习交流群
伙伴们,对AI测试、大模型评测、质量保障感兴趣吗?我们建了一个 「Playwright mcp技术学习交流群」,专门用来探讨相关技术、分享资料、互通有无。无论你是正在实践还是好奇探索,都欢迎扫码加入,一起抱团成长!期待与你交流!👇

构建命令行工具
我们还可以创建CLI工具来扩展Playwright的功能:
// src/cli/playwright-extend.js#!/usr/bin/env nodeconst { program } = require('commander');const { execSync } = require('child_process');const fs = require('fs').promises;const path = require('path');program .version('1.0.0') .description('Playwright扩展工具集');program .command('generate-test <name>') .description('生成测试模板') .option('-t, --type <type>', '测试类型 (e2e, component, api)', 'e2e') .option('-p, --path <path>', '生成路径', 'tests') .action(async (name, options) => {const template = await getTemplate(options.type);const testContent = template .replace(/{{name}}/g, name) .replace(/{{date}}/g, newDate().toISOString().split('T')[0]);const testPath = path.join(options.path, `${name}.test.js`);await fs.writeFile(testPath, testContent);console.log(`✅ 测试文件已生成: ${testPath}`); });program .command('visual-baseline') .description('生成视觉测试基线') .option('-u, --url <url>', '目标URL', 'http://localhost:3000') .action(async (options) => {console.log('📸 开始生成视觉测试基线...');const script = ` const { chromium } = require('playwright'); (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('${options.url}'); // 截图所有关键页面 const pages = ['/', '/dashboard', '/profile', '/settings']; for (const path of pages) { await page.goto('${options.url}' + path); await page.waitForLoadState('networkidle'); await page.screenshot({ path: \`visual-baseline/\${path.replace('/', '') || 'home'}.png\`, fullPage: true }); console.log(\`已截图: \${path}\`); } await browser.close(); })(); `; execSync(`node -e "${script.replace(/\n/g, ' ')}"`, {stdio: 'inherit' }); });program .command('performance-check') .description('运行性能检查') .action(async () => {const { chromium } = require('playwright');const browser = await chromium.launch();const page = await browser.newPage();// 监听性能指标await page.coverage.startJSCoverage();await page.coverage.startCSSCoverage();const client = await page.context().newCDPSession(page);await client.send('Performance.enable');await page.goto('http://localhost:3000');// 收集性能数据const metrics = await client.send('Performance.getMetrics');const jsCoverage = await page.coverage.stopJSCoverage();const cssCoverage = await page.coverage.stopCSSCoverage();console.log('\n📊 性能报告:');console.log('='.repeat(50)); metrics.metrics.forEach(metric => {console.log(`${metric.name.padEnd(30)}: ${Math.round(metric.value)}`); });console.log('\n🎯 代码覆盖率:');console.log(`JavaScript: ${calculateCoverage(jsCoverage)}%`);console.log(`CSS: ${calculateCoverage(cssCoverage)}%`);await browser.close(); });asyncfunctiongetTemplate(type) {const templates = {e2e: `const { test, expect } = require('@playwright/test');test.describe('{{name}}', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); }); test('should work correctly', async ({ page }) => { // 测试逻辑 await expect(page).toHaveTitle(/.*/); });});`,component: `import { test, expect } from '@playwright/experimental-ct-react';import {{name}} from './{{name}}';test.describe('{{name}} Component', () => { test('should render correctly', async ({ mount }) => { const component = await mount(<{{name}} />); await expect(component).toBeVisible(); });});` };return templates[type] || templates.e2e;}functioncalculateCoverage(coverage) {let totalBytes = 0;let usedBytes = 0; coverage.forEach(entry => { totalBytes += entry.text.length; entry.ranges.forEach(range => { usedBytes += range.end - range.start - 1; }); });return totalBytes > 0 ? ((usedBytes / totalBytes) * 100).toFixed(2) : 0;}program.parse(process.argv);
打包与发布
为了让团队其他成员能够使用你的插件,你需要将其打包发布:
// package.json{"name": "playwright-extensions","version": "1.0.0","description": "Custom extensions for Playwright","main": "dist/index.js","scripts": {"build": "babel src --out-dir dist","prepublishOnly": "npm run build","test": "playwright test" },"bin": {"playwright-extend": "./dist/cli/playwright-extend.js" },"files": ["dist","README.md" ],"peerDependencies": {"@playwright/test": "^1.40.0" },"dependencies": {"commander": "^11.0.0" },"devDependencies": {"@babel/cli": "^7.21.0","@babel/preset-env": "^7.21.0" }}
最佳实践建议
-
保持插件轻量:每个插件应该专注于单一职责 -
提供详细文档:包括安装、配置和使用示例 -
完善的错误处理:插件中的错误应该有清晰的提示信息 -
向后兼容:更新插件时尽量保持API的稳定性 -
充分的测试:为你的插件编写自动化测试
总结
开发Playwright自定义插件和工具可以显著提升团队的测试效率和代码质量。通过创建适合项目特定需求的扩展,我们能够构建更强大、更灵活的自动化测试框架。
记住,最好的插件往往来源于实际项目中的痛点。从解决一个小问题开始,逐步完善功能,最终你会构建出对团队真正有价值的工具集。开始动手吧,期待看到你创造的强大插件!

关于我们
霍格沃兹测试开发学社,隶属于 测吧(北京)科技有限公司,是一个面向软件测试爱好者的技术交流社区。
学社围绕现代软件测试工程体系展开,内容涵盖软件测试入门、自动化测试、性能测试、接口测试、测试开发、全栈测试,以及人工智能测试与 AI 在测试工程中的应用实践。
我们关注测试工程能力的系统化建设,包括 Python 自动化测试、Java 自动化测试、Web 与 App 自动化、持续集成与质量体系建设,同时探索 AI 驱动的测试设计、用例生成、自动化执行与质量分析方法,沉淀可复用、可落地的测试开发工程经验。
在技术社区与工程实践之外,学社还参与测试工程人才培养体系建设,面向高校提供测试实训平台与实践支持,组织开展 “火焰杯” 软件测试相关技术赛事,并探索以能力为导向的人才培养模式,包括高校学员先学习、就业后付款的实践路径。
同时,学社结合真实行业需求,为在职测试工程师与高潜学员提供名企大厂 1v1 私教服务,用于个性化能力提升与工程实践指导。
夜雨聆风
