Playwright是微软开源的现代化Web自动化测试框架,支持Chromium、WebKit和Firefox三大浏览器引擎。与传统的Selenium相比,Playwright最显著的特点是内置了自动等待机制,不需要手动添加sleep语句,同时提供了更丰富的API和更好的执行性能。
我在多个电商和金融项目中实际使用Playwright后发现,其跨浏览器支持能力特别适合需要兼容性验证的场景。比如最近一个跨国支付网关项目,就利用Playwright同时验证了Chrome、Safari和Firefox上的支付流程,测试脚本编写效率比之前用Selenium时提升了约40%。
注意:Playwright虽然支持三大浏览器,但实际使用的是这些浏览器的无头模式(headless),并非完全等同于真实用户环境。对于需要真实浏览器测试的场景,建议配合BrowserStack等云测试平台使用。
Playwright支持多种语言绑定,包括JavaScript/TypeScript、Python、Java和.NET。以Node.js环境为例,初始化步骤如下:
bash复制# 创建项目目录并初始化
mkdir playwright-demo && cd playwright-demo
npm init -y
# 安装Playwright
npm install playwright
# 安装浏览器二进制文件(约200MB)
npx playwright install
安装完成后,建议在项目根目录创建tests文件夹存放测试脚本。我通常会建立如下目录结构:
code复制playwright-demo/
├── tests/
│ ├── fixtures/ # 测试夹具
│ ├── pages/ # 页面对象模型
│ └── specs/ # 测试用例
├── playwright.config.js # 配置文件
└── package.json
Playwright的核心配置文件是playwright.config.js,以下是一个生产级配置示例:
javascript复制// playwright.config.js
const { devices } = require('@playwright/test');
module.exports = {
timeout: 30000, // 全局超时30秒
retries: 2, // 失败重试次数
workers: 3, // 并行worker数量
use: {
headless: false, // 调试时设为false
viewport: { width: 1280, height: 720 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure', // 保留失败用例的追踪
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
};
实际项目中我发现,将
trace设置为on虽然会记录所有测试的追踪数据,但会显著增加测试时间。推荐只在CI环境中对失败用例开启追踪。
Playwright提供了多种元素定位方式,以下是实际项目中最常用的几种:
javascript复制// 文本定位
await page.click('text=登录');
// CSS选择器
await page.fill('#username', 'testuser');
// XPath
await page.click('//button[@aria-label="搜索"]');
// 组合定位
await page.click('article:has-text("最新公告") >> button');
在电商项目实践中,我发现组合定位方式特别适合动态生成的DOM结构。比如处理一个商品列表时,可以这样定位特定商品的"加入购物车"按钮:
javascript复制const addToCart = page.locator('.product-item:has-text("iPhone 13") >> .add-cart');
await addToCart.click();
对于中大型项目,推荐使用Page Object模式。以下是用户登录页面的实现示例:
javascript复制// tests/pages/login.page.js
class LoginPage {
constructor(page) {
this.page = page;
this.username = page.locator('#username');
this.password = page.locator('#password');
this.submit = page.locator('button:has-text("登录")');
}
async navigate() {
await this.page.goto('https://example.com/login');
}
async login(username, password) {
await this.username.fill(username);
await this.password.fill(password);
await this.submit.click();
}
}
module.exports = LoginPage;
在测试用例中使用时:
javascript复制const { test } = require('@playwright/test');
const LoginPage = require('../pages/login.page');
test('用户登录测试', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login('testuser', 'password123');
// 验证登录成功
await expect(page).toHaveURL(/dashboard/);
});
处理文件上传时,Playwright的setInputFiles方法非常实用:
javascript复制// 单文件上传
await page.locator('input[type="file"]').setInputFiles('avatar.png');
// 多文件上传
await page.locator('input[type="file"]').setInputFiles([
'file1.pdf',
'file2.jpg'
]);
对于文件下载,需要等待download事件:
javascript复制const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('a:has-text("导出报表")')
]);
const path = await download.path(); // 临时文件路径
处理iframe时需要先定位到iframe元素:
javascript复制const frame = page.frameLocator('iframe[name="payment"]');
await frame.locator('#card-number').fill('4111111111111111');
对于新打开的标签页:
javascript复制const [newPage] = await Promise.all([
page.waitForEvent('popup'),
page.click('a[target="_blank"]')
]);
await newPage.waitForLoadState();
Playwright内置了HTML报告功能,在配置文件中添加:
javascript复制reporter: [
['list'],
['html', { outputFolder: 'playwright-report', open: 'never' }]
]
运行测试后生成报告:
bash复制npx playwright test --reporter=html
yaml复制name: Playwright Tests
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: npm ci
- run: npx playwright install
- run: npx playwright test
- uses: actions/upload-artifact@v2
if: always()
with:
name: playwright-report
path: playwright-report/
并行执行:通过--workers参数控制并行度
bash复制npx playwright test --workers=4
选择性执行:使用--grep过滤测试用例
bash复制npx playwright test --grep="登录"
禁用不必要的操作:在配置中关闭不需要的功能
javascript复制use: {
video: 'off',
screenshot: 'off'
}
问题1:元素定位失败,但页面确实存在该元素
await page.waitForSelector('#element')page.pause()进入调试模式问题2:跨域请求被拦截
javascript复制await page.route('**/api/*', route => {
route.continue({url: route.request().url() + '?token=test'});
});
问题3:CI环境中测试不稳定
retries: 2Playwright支持通过设备描述符模拟移动设备:
javascript复制const { devices } = require('@playwright/test');
const iPhone11 = devices['iPhone 11 Pro'];
const browser = await playwright.chromium.launch();
const context = await browser.newContext({
...iPhone11,
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai'
});
实际项目中,我发现以下配置对移动端测试特别有用:
javascript复制// 模拟触摸事件
await page.tap('text=确认');
// 模拟横屏模式
await page.setViewportSize({
width: 896,
height: 414
});
// 模拟地理位置
await context.setGeolocation({ latitude: 39.9042, longitude: 116.4074 });
虽然Playwright本身不提供视觉对比功能,但可以结合第三方库实现:
javascript复制const { test, expect } = require('@playwright/test');
const { toMatchImageSnapshot } = require('jest-image-snapshot');
expect.extend({ toMatchImageSnapshot });
test('首页视觉回归测试', async ({ page }) => {
await page.goto('https://example.com');
const screenshot = await page.screenshot({ fullPage: true });
expect(screenshot).toMatchImageSnapshot({
failureThreshold: 0.01,
failureThresholdType: 'percent'
});
});
在金融项目中,我们建立了以下视觉测试流程:
javascript复制// tests/fixtures/users.js
module.exports = {
adminUser: {
username: 'admin',
password: 'securePass123',
role: 'administrator'
},
customerUser: {
username: 'customer1',
password: 'userPass456',
role: 'customer'
}
};
在测试用例中使用:
javascript复制const { test } = require('@playwright/test');
const users = require('../fixtures/users');
test('管理员登录测试', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login(users.adminUser.username, users.adminUser.password);
});
对于需要直接操作数据库的测试:
javascript复制const { MongoClient } = require('mongodb');
async function resetTestDB() {
const client = new MongoClient('mongodb://localhost:27017');
await client.connect();
const db = client.db('test');
await db.collection('users').deleteMany({});
await db.collection('users').insertMany([
{ name: '测试用户1', status: 'active' },
{ name: '测试用户2', status: 'inactive' }
]);
await client.close();
}
// 在beforeAll或beforeEach中调用
test.beforeEach(async () => {
await resetTestDB();
});
javascript复制const xssPayloads = [
'<script>alert(1)</script>',
'<img src=x onerror=alert(1)>',
'javascript:alert(1)'
];
test('搜索框XSS测试', async ({ page }) => {
await page.goto('https://example.com');
const searchInput = page.locator('#search');
for (const payload of xssPayloads) {
await searchInput.fill(payload);
await page.keyboard.press('Enter');
// 检查是否弹出alert
const dialog = await new Promise(resolve => {
page.on('dialog', resolve);
setTimeout(() => resolve(null), 1000);
});
if (dialog) {
throw new Error(`发现XSS漏洞: ${payload}`);
}
}
});
javascript复制test('关键操作CSRF防护验证', async ({ browser }) => {
// 创建两个独立上下文模拟不同用户
const context1 = await browser.newContext();
const context2 = await browser.newContext();
const page1 = await context1.newPage();
const page2 = await context2.newPage();
// 用户1正常登录
await page1.goto('https://example.com/login');
await page1.fill('#username', 'user1');
await page1.fill('#password', 'pass123');
await page1.click('button[type="submit"]');
// 获取用户1的cookie
const cookies = await context1.cookies();
// 尝试在用户2的上下文中使用用户1的cookie
await context2.addCookies(cookies);
// 尝试执行敏感操作
await page2.goto('https://example.com/delete-account');
await expect(page2.locator('.error-message')).toHaveText('无效的CSRF令牌');
});
javascript复制test('购物车全流程测试', async ({ request, page }) => {
// 通过API添加商品
const addResponse = await request.post('/api/cart/add', {
data: { productId: 123, quantity: 2 }
});
expect(addResponse.ok()).toBeTruthy();
// 通过UI验证
await page.goto('/cart');
await expect(page.locator('.cart-item')).toHaveCount(1);
await expect(page.locator('.total-amount')).toHaveText('¥1998.00');
});
对于数值输入字段:
javascript复制const testCases = [
{ value: '', expected: '请输入数量' },
{ value: '0', expected: '数量必须大于0' },
{ value: '1', expected: '' },
{ value: '999', expected: '' },
{ value: '1000', expected: '数量不能超过999' },
{ value: 'abc', expected: '请输入有效数字' }
];
test('商品数量输入边界测试', async ({ page }) => {
await page.goto('/product/123');
const quantityInput = page.locator('#quantity');
for (const tc of testCases) {
await quantityInput.fill(tc.value);
await page.click('#add-to-cart');
if (tc.expected) {
await expect(page.locator('.error-message')).toHaveText(tc.expected);
} else {
await expect(page.locator('.success-message')).toBeVisible();
}
}
});
javascript复制// 自定义toBeWithinRange断言
expect.extend({
async toBeWithinRange(received, floor, ceiling) {
const value = await received;
const pass = value >= floor && value <= ceiling;
return {
message: () => `expected ${value} to be within range ${floor}-${ceiling}`,
pass
};
}
});
// 使用示例
test('响应时间测试', async ({ page }) => {
const start = Date.now();
await page.goto('https://example.com');
const loadTime = Date.now() - start;
await expect(loadTime).toBeWithinRange(0, 1000);
});
javascript复制// custom-reporter.js
class MyReporter {
constructor(options) {
this.options = options;
}
onBegin(config, suite) {
console.log(`开始执行 ${suite.allTests().length} 个测试用例`);
}
onTestEnd(test, result) {
console.log(`${test.title}: ${result.status}`);
}
}
module.exports = MyReporter;
在配置中使用:
javascript复制reporter: [
['list'],
['./custom-reporter.js', { outputFile: 'results.txt' }]
]
在金融项目中,我们采用以下策略保证测试隔离:
javascript复制test.beforeEach(async ({ request }) => {
// 创建唯一测试用户
const userId = uuidv4();
await request.post('/api/test-users', {
data: { id: userId, role: 'tester' }
});
});
test.afterEach(async ({ request }) => {
// 清理测试用户创建的所有数据
await request.delete('/api/cleanup');
});
javascript复制// health-check.js
const { chromium } = require('playwright');
async function checkEnvironment() {
const browser = await chromium.launch();
const page = await browser.newPage();
try {
// 检查基础URL可访问
await page.goto(process.env.BASE_URL);
// 检查API端点
const apiResponse = await page.request.get(`${process.env.API_URL}/health`);
if (apiResponse.status() !== 200) {
throw new Error('API服务不可用');
}
// 检查数据库连接
const dbResponse = await page.request.post(`${process.env.API_URL}/db-check`);
if (!dbResponse.ok()) {
throw new Error('数据库连接异常');
}
console.log('环境检查通过');
return true;
} catch (error) {
console.error('环境检查失败:', error.message);
return false;
} finally {
await browser.close();
}
}
module.exports = checkEnvironment;
javascript复制// tests/utils/auth.js
async function loginAs(page, role) {
const users = {
admin: { username: 'admin', password: 'Admin@123' },
customer: { username: 'customer1', password: 'Customer@123' }
};
const user = users[role];
if (!user) throw new Error(`未知角色: ${role}`);
await page.goto('/login');
await page.fill('#username', user.username);
await page.fill('#password', user.password);
await page.click('button[type="submit"]');
await page.waitForURL(/dashboard/);
}
module.exports = { loginAs };
javascript复制test.describe('管理员面板测试', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'admin');
await page.goto('/admin');
});
test('用户管理', async ({ page }) => {
// 不需要重复登录逻辑
});
test('系统设置', async ({ page }) => {
// 不需要重复登录逻辑
});
});
根据项目经验,推荐以下测试比例:
Playwright最适合用于金字塔顶端的UI测试和中间的API测试。
好的测试用例示例:
javascript复制test('未登录用户访问个人中心应跳转到登录页', async ({ page }) => {
await page.goto('/profile');
await expect(page).toHaveURL(/login/);
await expect(page.locator('.login-title')).toBeVisible();
});
code复制e2e/
├── config/ # 环境配置
├── fixtures/ # 测试夹具
├── pages/ # 页面对象
├── specs/ # 测试用例
│ ├── smoke/ # 冒烟测试
│ ├── regression/ # 回归测试
│ └── features/ # 按功能划分
├── utils/ # 工具函数
└── workflows/ # 复合工作流
javascript复制// config/env.js
const environments = {
dev: {
baseURL: 'https://dev.example.com',
apiURL: 'https://api.dev.example.com',
users: {
admin: { username: 'dev_admin', password: 'dev_pass' }
}
},
staging: {
baseURL: 'https://staging.example.com',
apiURL: 'https://api.staging.example.com',
users: {
admin: { username: 'stage_admin', password: 'stage_pass' }
}
}
};
module.exports = environments[process.env.TEST_ENV || 'dev'];
javascript复制// tests/utils/performance.js
const fs = require('fs');
const path = require('path');
const performanceLog = path.join(__dirname, '../../perf.log');
function startTimer() {
return {
name: null,
start: null,
setTestName(name) {
this.name = name;
this.start = Date.now();
},
record() {
const duration = Date.now() - this.start;
fs.appendFileSync(performanceLog,
`${new Date().toISOString()},${this.name},${duration}ms\n`);
}
};
}
module.exports = { startTimer };
在测试中使用:
javascript复制const { startTimer } = require('../utils/performance');
const timer = startTimer();
test('商品搜索性能测试', async ({ page }) => {
timer.setTestName('商品搜索');
await page.goto('/');
await page.fill('#search', '手机');
await page.click('#search-btn');
await expect(page.locator('.product-item')).toBeVisible();
timer.record();
});
通过以下脚本分析失败率高的测试:
javascript复制const fs = require('fs');
const path = require('path');
const { parse } = require('csv-parse/sync');
const results = parse(fs.readFileSync('test-results.csv'), {
columns: true,
skip_empty_lines: true
});
const testStats = {};
results.forEach(row => {
if (!testStats[row.test]) {
testStats[row.test] = { total: 0, failed: 0 };
}
testStats[row.test].total++;
if (row.status === 'failed') {
testStats[row.test].failed++;
}
});
console.log('测试稳定性报告:');
Object.entries(testStats).forEach(([test, stats]) => {
const failureRate = (stats.failed / stats.total * 100).toFixed(1);
console.log(`${test}: ${stats.failed}/${stats.total} (${failureRate}%)`);
});
javascript复制const { test, expect } = require('@playwright/test');
const axe = require('axe-playwright');
test('首页无障碍测试', async ({ page }) => {
await page.goto('/');
// 注入axe-core运行时
await axe.inject(page);
// 运行无障碍检查
const results = await axe.run(page);
// 验证没有严重违规
expect(results.violations.filter(v => v.impact === 'serious')).toEqual([]);
// 输出详细结果
if (results.violations.length > 0) {
console.log('无障碍问题发现:');
results.violations.forEach(violation => {
console.log(`- [${violation.impact}] ${violation.description}`);
console.log(` 帮助: ${violation.helpUrl}`);
});
}
});
javascript复制test('键盘导航测试', async ({ page }) => {
await page.goto('/');
// Tab键导航
await page.keyboard.press('Tab');
await expect(page.locator(':focus')).toHaveAttribute('id', 'search-input');
// 回车键操作
await page.keyboard.type('手机');
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/search/);
});
javascript复制const locales = ['en-US', 'zh-CN', 'ja-JP'];
for (const locale of locales) {
test(`首页显示正确语言 (${locale})`, async ({ page }) => {
await page.goto(`/?lang=${locale}`);
// 验证语言切换器状态
await expect(page.locator('#language-selector'))
.toHaveValue(locale);
// 验证翻译文本
const expectedTexts = {
'en-US': 'Welcome',
'zh-CN': '欢迎',
'ja-JP': 'ようこそ'
};
await expect(page.locator('.welcome-message'))
.toHaveText(expectedTexts[locale]);
});
}
javascript复制test('阿拉伯语RTL布局测试', async ({ page }) => {
await page.goto('/?lang=ar');
// 验证文档方向
const isRTL = await page.evaluate(() => {
return document.documentElement.dir === 'rtl';
});
expect(isRTL).toBeTruthy();
// 验证主要元素对齐方式
const navStyles = await page.locator('nav').evaluate(el => {
return window.getComputedStyle(el).textAlign;
});
expect(navStyles).toBe('right');
});
在团队实践中,我们使用以下审查清单:
可读性
可靠性
可维护性
性能
重构前:
javascript复制test('添加商品到购物车', async ({ page }) => {
await page.goto('/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL(/dashboard/);
await page.goto('/product/123');
await page.click('#add-to-cart');
await expect(page.locator('.cart-count')).toHaveText('1');
});
重构后:
javascript复制test.describe('购物车功能', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'customer');
await page.goto('/product/123');
});
test('添加单个商品', async ({ page }) => {
await page.click('#add-to-cart');
await expectCartCount(page, 1);
});
test('添加多个商品', async ({ page }) => {
await page.fill('#quantity', '3');
await page.click('#add-to-cart');
await expectCartCount(page, 3);
});
});
async function expectCartCount(page, expected) {
await expect(page.locator('.cart-count')).toHaveText(expected.toString());
}