在自动化测试的实践中,我们经常会遇到重复性的任务和特定的业务需求,而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.js
const { test: baseTest, expect } = require('@playwright/test');
const fs = require('fs').promises;
const path = require('path');
class AuthManager {
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.py
import json
import os
from pathlib import Path
from typing import Optional, Dict, Any
import pytest
from playwright.sync_api import Page, BrowserContext
class AuthManager:
def init(self, page: Page, storage_state_path: str):
self.page = page
self.storage_state_path = storage_state_path
def login(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.page
def save_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)
def restore_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逻辑
pass
except FileNotFoundError:
print('No saved auth state found, proceeding with fresh session')
@pytest.fixture
def auth(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.fixture
def authenticated_page(auth: AuthManager, page: Page) -> Page:
"""返回已认证的页面"""
ifnot page.locator('.user-profile').is_visible():
auth.login()
return page
开发自定义Reporter
当内置的报告器不能满足需求时,我们可以创建自定义的报告器。以下是一个集成Slack通知的报告器示例:
// src/reporters/slack-reporter.js
class SlackReporter {
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.js
class POMManager {
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);
}
}
// 组件基类
class BaseComponent {
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 }
);
}
}
// 使用示例:导航栏组件
class NavigationBar extends BaseComponent {
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 };
构建命令行工具
我们还可以创建CLI工具来扩展Playwright的功能:
// src/cli/playwright-extend.js
!/usr/bin/env node
const { 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 ')
.description('生成测试模板')
.option('-t, --type ', '测试类型 (e2e, component, api)', 'e2e')
.option('-p, --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', '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();
});
asyncfunction getTemplate(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;
}
function calculateCoverage(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 私教服务,用于个性化能力提升与工程实践指导。