E2E 测试 (Playwright/Cypress)
概述
E2E(端到端)测试模拟真实用户场景,验证整个应用流程的正确性。
一、框架对比
| 特性 | Playwright | Cypress |
|---|---|---|
| 浏览器支持 | Chromium, Firefox, WebKit | Chrome, Firefox, Edge |
| 语言 | JS/TS, Python, Java, C# | JS/TS |
| 并行执行 | ✅ 原生支持 | 需付费 Dashboard |
| 多标签页 | ✅ | ❌ |
| iframe | ✅ 简单 | 复杂 |
| 网络拦截 | ✅ 强大 | ✅ |
| 移动端模拟 | ✅ | 有限 |
| 执行速度 | 快 | 中等 |
| 调试体验 | 好 | 优秀 (时间旅行) |
| 学习曲线 | 中等 | 简单 |
二、Playwright 入门
安装
bash
npm init playwright@latest目录结构
tests/
├── example.spec.ts
├── fixtures/
│ └── test-data.json
└── pages/
└── login.page.ts
playwright.config.ts配置文件
typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
// 并行执行
fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
// 重试
retries: process.env.CI ? 2 : 0,
// 报告
reporter: [
['html'],
['junit', { outputFile: 'results.xml' }]
],
// 全局设置
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// 多浏览器测试
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// 启动开发服务器
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});基础测试
typescript
import { test, expect } from '@playwright/test';
test.describe('Login Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('successful login', async ({ page }) => {
// 填写表单
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
// 点击登录
await page.getByRole('button', { name: 'Login' }).click();
// 验证跳转
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
test('shows error for invalid credentials', async ({ page }) => {
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('alert')).toContainText('Invalid');
});
});三、Playwright 高级用法
定位器策略
typescript
// 推荐:语义化定位
page.getByRole('button', { name: 'Submit' });
page.getByLabel('Username');
page.getByPlaceholder('Enter email');
page.getByText('Welcome');
page.getByAltText('Logo');
page.getByTestId('submit-button'); // 最后手段
// CSS/XPath (不推荐)
page.locator('.submit-btn');
page.locator('//button[@type="submit"]');
// 链式定位
page.getByRole('listitem').filter({ hasText: 'Product 1' }).getByRole('button');
// 第 N 个元素
page.getByRole('listitem').nth(0);
page.getByRole('listitem').first();
page.getByRole('listitem').last();等待策略
typescript
// 自动等待 (Playwright 默认)
await page.click('button'); // 自动等待可见、可点击
// 显式等待
await page.waitForSelector('.loaded');
await page.waitForURL('**/dashboard');
await page.waitForLoadState('networkidle');
await page.waitForResponse('**/api/data');
await page.waitForTimeout(1000); // 尽量避免
// 断言自动重试
await expect(page.getByText('Done')).toBeVisible({ timeout: 5000 });网络拦截
typescript
// Mock API
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Mock User' }])
});
});
// 修改响应
await page.route('**/api/users', async route => {
const response = await route.fetch();
const json = await response.json();
json.push({ id: 999, name: 'Injected' });
await route.fulfill({ response, json });
});
// 阻止请求
await page.route('**/*.png', route => route.abort());
// 等待请求
const responsePromise = page.waitForResponse('**/api/data');
await page.click('button');
const response = await responsePromise;多标签页与弹窗
typescript
// 新标签页
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.click('a[target="_blank"]')
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveTitle('New Page');
// 弹窗处理
page.on('dialog', dialog => {
console.log(dialog.message());
dialog.accept();
});
await page.click('button#alert');文件操作
typescript
// 上传文件
await page.setInputFiles('input[type="file"]', 'path/to/file.pdf');
await page.setInputFiles('input', ['file1.pdf', 'file2.pdf']); // 多文件
// 下载文件
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('a#download')
]);
await download.saveAs('downloaded-file.pdf');四、页面对象模型 (POM)
typescript
// pages/login.page.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Login' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';
test('login with POM', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});五、Cypress 入门
安装与配置
bash
npm install --save-dev cypress
npx cypress openjavascript
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
},
});基础测试
javascript
describe('Login', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in successfully', () => {
cy.get('[data-cy="email"]').type('user@example.com');
cy.get('[data-cy="password"]').type('password123');
cy.get('[data-cy="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome').should('be.visible');
});
});Cypress 命令
javascript
// 查询
cy.get('.selector');
cy.contains('text');
cy.get('form').find('input');
// 交互
cy.get('input').type('hello');
cy.get('button').click();
cy.get('select').select('option1');
cy.get('input[type="checkbox"]').check();
// 断言
cy.get('.element').should('be.visible');
cy.get('.element').should('have.text', 'Hello');
cy.get('.element').should('have.class', 'active');
cy.url().should('include', '/dashboard');
// 网络
cy.intercept('GET', '/api/users', { fixture: 'users.json' });
cy.intercept('POST', '/api/login').as('login');
cy.wait('@login');六、视觉回归测试
Playwright 截图对比
typescript
test('visual regression', async ({ page }) => {
await page.goto('/');
// 全页截图
await expect(page).toHaveScreenshot('homepage.png');
// 元素截图
await expect(page.getByRole('header')).toHaveScreenshot('header.png');
// 配置选项
await expect(page).toHaveScreenshot('page.png', {
maxDiffPixels: 100,
threshold: 0.2,
animations: 'disabled',
mask: [page.locator('.dynamic-content')]
});
});更新快照
bash
npx playwright test --update-snapshots七、CI 集成
GitHub Actions
yaml
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run tests
run: npx playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/面试高频题
Q1: Playwright 和 Cypress 如何选择?
答:
- 选 Playwright: 需要多浏览器、多标签页、移动端模拟、或使用非 JS 语言
- 选 Cypress: 团队熟悉、看重调试体验(时间旅行)、简单应用
Q2: E2E 测试覆盖多少合适?
答:
- 遵循测试金字塔:E2E < 集成测试 < 单元测试
- 覆盖关键用户路径(登录、核心业务流程、支付等)
- 一般 10-20% 的测试为 E2E
Q3: 如何提高 E2E 测试稳定性?
答:
- 使用语义化定位器,避免 CSS/XPath
- 等待明确的 UI 状态,避免 sleep
- 使用 Mock API 隔离后端不稳定性
- 重试机制处理偶发失败
- 并行执行时确保测试隔离
Q4: 如何处理测试数据?
答:
- Fixtures: 静态测试数据文件
- Factory: 动态生成测试数据
- API Setup: beforeEach 中通过 API 创建数据
- 数据库 Seed: 测试前重置数据库