乐于分享
好东西不私藏

Playwright扩展开发:自定义插件与工具创建

Playwright扩展开发:自定义插件与工具创建

关注 霍格沃兹测试学院公众号,回复「资料」, 领取人工智能测试开发技术合集

在自动化测试的实践中,我们经常会遇到重复性的任务和特定的业务需求,而Playwright的原生功能并不总能完全满足这些需求。这时候,开发自定义插件和工具就显得尤为重要。本文将带你深入探索如何为Playwright创建功能强大的扩展。

为什么要开发自定义插件?

在我多年的测试自动化经验中,我发现团队经常会遇到这些情况:

  1. 重复代码片段在不同测试文件中频繁出现
  2. 特定业务逻辑需要封装成可重用组件
  3. 第三方服务集成需要统一处理
  4. 团队规范需要强制执行

自定义插件正是解决这些问题的利器。它们不仅能够提高代码复用性,还能让测试代码更加简洁、可维护。

环境准备与基础架构

开始之前,确保你已经安装了最新版本的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, null2)    );  }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({authasync ({ 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();    }  },authenticatedPageasync ({ 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({totalTeststhis.totalTests,passedTeststhis.passedTests,failedTeststhis.failedTests,passRate: passRate,duration: duration,statusthis.failedTests === 0 ? 'success' : 'failure'    });  }async sendImmediateAlert(test, result) {if (!this.webhookUrl) return;const message = {channelthis.channel,usernamethis.username,attachments: [{color'danger',title`❌ 测试失败: ${test.title}`,fields: [          {title'文件',value: test.location.file,shorttrue          },          {title'执行时间',value`${result.duration}ms`,shorttrue          }        ],text`错误信息:\n\`\`\`${result.error?.message || 'Unknown error'}\`\`\``,footer'Playwright Test Runner',tsMath.floor(Date.now() / 1000)      }]    };awaitthis.sendToSlack(message);  }async sendSummaryReport(stats) {if (!this.webhookUrl) return;const message = {channelthis.channel,usernamethis.username,attachments: [{color: stats.status === 'success' ? 'good' : 'danger',title`📊 测试执行完成`,fields: [          {title'总测试数',value: stats.totalTests.toString(),shorttrue          },          {title'通过',value: stats.passedTests.toString(),shorttrue          },          {title'失败',value: stats.failedTests.toString(),shorttrue          },          {title'通过率',value`${stats.passRate}%`,shorttrue          },          {title'执行时间',value`${stats.duration}秒`,shorttrue          }        ],footer'Playwright Test Runner',tsMath.floor(Date.now() / 1000)      }]    };awaitthis.sendToSlack(message);  }async sendToSlack(message) {try {const response = await fetch(this.webhookUrl, {method'POST',headers: {'Content-Type''application/json',        },bodyJSON.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({pagethis.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}}/gnewDate().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"  }}

最佳实践建议

  1. 保持插件轻量:每个插件应该专注于单一职责
  2. 提供详细文档:包括安装、配置和使用示例
  3. 完善的错误处理:插件中的错误应该有清晰的提示信息
  4. 向后兼容:更新插件时尽量保持API的稳定性
  5. 充分的测试:为你的插件编写自动化测试

总结

开发Playwright自定义插件和工具可以显著提升团队的测试效率和代码质量。通过创建适合项目特定需求的扩展,我们能够构建更强大、更灵活的自动化测试框架。

记住,最好的插件往往来源于实际项目中的痛点。从解决一个小问题开始,逐步完善功能,最终你会构建出对团队真正有价值的工具集。开始动手吧,期待看到你创造的强大插件!

关于我们

霍格沃兹测试开发学社,隶属于 测吧(北京)科技有限公司,是一个面向软件测试爱好者的技术交流社区。

学社围绕现代软件测试工程体系展开,内容涵盖软件测试入门、自动化测试、性能测试、接口测试、测试开发、全栈测试,以及人工智能测试与 AI 在测试工程中的应用实践

我们关注测试工程能力的系统化建设,包括 Python 自动化测试、Java 自动化测试、Web 与 App 自动化、持续集成与质量体系建设,同时探索 AI 驱动的测试设计、用例生成、自动化执行与质量分析方法,沉淀可复用、可落地的测试开发工程经验。

在技术社区与工程实践之外,学社还参与测试工程人才培养体系建设,面向高校提供测试实训平台与实践支持,组织开展 “火焰杯” 软件测试相关技术赛事,并探索以能力为导向的人才培养模式,包括高校学员先学习、就业后付款的实践路径。

同时,学社结合真实行业需求,为在职测试工程师与高潜学员提供名企大厂 1v1 私教服务,用于个性化能力提升与工程实践指导。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » Playwright扩展开发:自定义插件与工具创建

评论 抢沙发

7 + 2 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮