Appearance
E2E测试最佳实践
概述
端到端(E2E)测试是验证应用在真实环境中运行的关键手段。编写高质量的E2E测试需要遵循一系列最佳实践,包括测试策略、代码组织、性能优化以及维护方法。本文将全面介绍E2E测试的最佳实践,帮助你构建稳定、可靠、易维护的测试套件。
测试策略
测试金字塔
/\
/E2E\ 少量(10-20%)
/----\
/集成测试\ 中等(30-40%)
/--------\
/ 单元测试 \ 大量(50-60%)
/------------\确定测试范围
typescript
// ✅ 测试关键用户流程
describe('Critical User Journeys', () => {
it('user can complete purchase', () => {
// 登录 -> 浏览 -> 添加购物车 -> 结账 -> 支付
});
it('user can register new account', () => {
// 注册 -> 验证邮箱 -> 完善信息 -> 完成
});
});
// ❌ 不要测试所有细节
describe('Over-testing', () => {
it('button has correct color', () => {
// 这应该是单元测试或视觉回归测试
});
it('input accepts 100 characters', () => {
// 这应该是单元测试
});
});测试优先级
typescript
// 1. P0 - 关键业务流程(必须测试)
describe('P0: Critical Flows', () => {
it('user login and checkout', () => {});
it('payment processing', () => {});
});
// 2. P1 - 重要功能(应该测试)
describe('P1: Important Features', () => {
it('product search and filter', () => {});
it('user profile update', () => {});
});
// 3. P2 - 辅助功能(可选测试)
describe('P2: Secondary Features', () => {
it('newsletter subscription', () => {});
it('social media sharing', () => {});
});代码组织
Page Object Model
typescript
// pages/BasePage.ts
export class BasePage {
constructor(protected page: Page) {}
async navigate(path: string) {
await this.page.goto(path);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
}
// pages/LoginPage.ts
export class LoginPage extends BasePage {
private selectors = {
email: '[data-testid="email"]',
password: '[data-testid="password"]',
submitButton: '[data-testid="submit"]',
errorMessage: '[data-testid="error"]',
};
async navigate() {
await super.navigate('/login');
}
async fillEmail(email: string) {
await this.page.fill(this.selectors.email, email);
}
async fillPassword(password: string) {
await this.page.fill(this.selectors.password, password);
}
async submit() {
await this.page.click(this.selectors.submitButton);
}
async login(email: string, password: string) {
await this.fillEmail(email);
await this.fillPassword(password);
await this.submit();
}
async getErrorMessage() {
return await this.page.textContent(this.selectors.errorMessage);
}
}
// pages/DashboardPage.ts
export class DashboardPage extends BasePage {
private selectors = {
welcomeMessage: '[data-testid="welcome"]',
logoutButton: '[data-testid="logout"]',
};
async isLoaded() {
await this.page.waitForSelector(this.selectors.welcomeMessage);
}
async getWelcomeMessage() {
return await this.page.textContent(this.selectors.welcomeMessage);
}
async logout() {
await this.page.click(this.selectors.logoutButton);
}
}
// 测试使用
import { test } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.navigate();
await loginPage.login('user@example.com', 'password');
await dashboardPage.isLoaded();
const message = await dashboardPage.getWelcomeMessage();
expect(message).toContain('Welcome');
});组件封装
typescript
// components/FormComponent.ts
export class FormComponent {
constructor(private page: Page, private formSelector: string) {}
async fillField(name: string, value: string) {
const selector = `${this.formSelector} [name="${name}"]`;
await this.page.fill(selector, value);
}
async selectOption(name: string, value: string) {
const selector = `${this.formSelector} select[name="${name}"]`;
await this.page.selectOption(selector, value);
}
async submit() {
await this.page.click(`${this.formSelector} button[type="submit"]`);
}
}
// components/ModalComponent.ts
export class ModalComponent {
constructor(private page: Page) {}
async waitForModal() {
await this.page.waitForSelector('[role="dialog"]');
}
async close() {
await this.page.click('[aria-label="Close"]');
}
async getTitle() {
return await this.page.textContent('[role="dialog"] h2');
}
}
// 使用组件
test('fills checkout form', async ({ page }) => {
const checkoutForm = new FormComponent(page, '#checkout-form');
await checkoutForm.fillField('name', 'John Doe');
await checkoutForm.fillField('address', '123 Main St');
await checkoutForm.selectOption('country', 'US');
await checkoutForm.submit();
});测试辅助工具
typescript
// utils/testHelpers.ts
export class TestHelpers {
static async loginAsUser(page: Page, role: 'admin' | 'user') {
const credentials = {
admin: { email: 'admin@test.com', password: 'admin123' },
user: { email: 'user@test.com', password: 'user123' },
};
await page.goto('/login');
await page.fill('[name="email"]', credentials[role].email);
await page.fill('[name="password"]', credentials[role].password);
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
}
static async clearDatabase() {
// 清空测试数据库
}
static async seedDatabase() {
// 填充测试数据
}
static generateRandomEmail() {
return `test-${Date.now()}@example.com`;
}
static async takeScreenshot(page: Page, name: string) {
await page.screenshot({
path: `screenshots/${name}-${Date.now()}.png`,
fullPage: true,
});
}
}
// 使用辅助工具
test('admin can manage users', async ({ page }) => {
await TestHelpers.loginAsUser(page, 'admin');
// 测试逻辑
await TestHelpers.takeScreenshot(page, 'admin-dashboard');
});选择器最佳实践
优先级顺序
typescript
// 1. data-testid (最推荐)
await page.click('[data-testid="submit-button"]');
// 2. role和accessible name
await page.getByRole('button', { name: 'Submit' }).click();
// 3. label
await page.getByLabel('Email').fill('test@example.com');
// 4. placeholder
await page.getByPlaceholder('Enter email').fill('test@example.com');
// 5. text content(谨慎使用,避免国际化问题)
await page.getByText('Submit').click();
// ❌ 避免使用
await page.click('.btn-primary'); // CSS类名容易变化
await page.click('#submit'); // ID可能不稳定
await page.click('button:nth-child(2)'); // 位置依赖统一选择器管理
typescript
// selectors/index.ts
export const selectors = {
auth: {
login: {
email: '[data-testid="login-email"]',
password: '[data-testid="login-password"]',
submit: '[data-testid="login-submit"]',
error: '[data-testid="login-error"]',
},
register: {
name: '[data-testid="register-name"]',
email: '[data-testid="register-email"]',
password: '[data-testid="register-password"]',
confirmPassword: '[data-testid="register-confirm-password"]',
submit: '[data-testid="register-submit"]',
},
},
product: {
card: '[data-testid="product-card"]',
title: '[data-testid="product-title"]',
price: '[data-testid="product-price"]',
addToCart: '[data-testid="add-to-cart"]',
},
};
// 使用
import { selectors } from './selectors';
test('user can login', async ({ page }) => {
await page.fill(selectors.auth.login.email, 'user@example.com');
await page.fill(selectors.auth.login.password, 'password');
await page.click(selectors.auth.login.submit);
});数据管理
测试数据工厂
typescript
// factories/userFactory.ts
export class UserFactory {
static createUser(overrides: Partial<User> = {}): User {
return {
id: Math.random().toString(),
email: `test-${Date.now()}@example.com`,
name: 'Test User',
role: 'user',
createdAt: new Date().toISOString(),
...overrides,
};
}
static createAdmin(overrides: Partial<User> = {}): User {
return this.createUser({
role: 'admin',
...overrides,
});
}
static createBatch(count: number): User[] {
return Array.from({ length: count }, () => this.createUser());
}
}
// 使用
test('displays user list', async ({ page }) => {
const users = UserFactory.createBatch(10);
await page.route('/api/users', (route) => {
route.fulfill({ json: users });
});
await page.goto('/users');
const userElements = await page.locator('[data-testid="user-item"]').count();
expect(userElements).toBe(10);
});Fixtures管理
typescript
// fixtures/index.ts
export const fixtures = {
users: {
admin: {
email: 'admin@example.com',
password: 'admin123',
name: 'Admin User',
},
user: {
email: 'user@example.com',
password: 'user123',
name: 'Regular User',
},
},
products: [
{ id: '1', name: 'Product 1', price: 29.99 },
{ id: '2', name: 'Product 2', price: 49.99 },
],
};
// 使用
test('admin can manage products', async ({ page }) => {
const { admin } = fixtures.users;
await page.goto('/login');
await page.fill('[name="email"]', admin.email);
await page.fill('[name="password"]', admin.password);
await page.click('button[type="submit"]');
});等待策略
智能等待
typescript
// ✅ 好的实践 - 等待特定条件
test('waits intelligently', async ({ page }) => {
// 等待元素可见
await page.waitForSelector('[data-testid="content"]', {
state: 'visible',
});
// 等待网络空闲
await page.waitForLoadState('networkidle');
// 等待特定请求
await page.waitForResponse('/api/data');
// 等待函数返回true
await page.waitForFunction(() => window.dataLoaded === true);
});
// ❌ 差的实践 - 固定等待
test('uses fixed waits', async ({ page }) => {
await page.waitForTimeout(5000); // 不好
});自定义等待辅助函数
typescript
// utils/waitHelpers.ts
export class WaitHelpers {
static async waitForApiCall(page: Page, urlPattern: string | RegExp) {
return await page.waitForResponse((response) => {
const url = response.url();
return typeof urlPattern === 'string'
? url.includes(urlPattern)
: urlPattern.test(url);
});
}
static async waitForElement(
page: Page,
selector: string,
options: { timeout?: number; state?: 'attached' | 'visible' } = {}
) {
await page.waitForSelector(selector, {
timeout: options.timeout || 10000,
state: options.state || 'visible',
});
}
static async waitForLoadingToFinish(page: Page) {
await page.waitForSelector('[data-testid="loading"]', {
state: 'hidden',
});
}
}错误处理
优雅的失败处理
typescript
test('handles errors gracefully', async ({ page }) => {
try {
await page.goto('/dashboard', { timeout: 5000 });
} catch (error) {
// 截图以便调试
await page.screenshot({ path: 'error-screenshot.png' });
console.error('Failed to load dashboard:', error);
throw error;
}
});重试机制
typescript
// playwright.config.ts
export default {
retries: process.env.CI ? 2 : 0, // CI环境重试2次
use: {
// 操作重试
actionTimeout: 10000,
},
};
// 自定义重试逻辑
async function retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
throw new Error('Operation failed after retries');
}性能优化
并行执行
typescript
// playwright.config.ts
export default {
workers: process.env.CI ? 2 : undefined, // CI中使用2个worker
fullyParallel: true, // 完全并行
};
// 测试文件级别控制
test.describe.configure({ mode: 'parallel' });
test.describe('Suite 1', () => {
test('test 1', async () => {});
test('test 2', async () => {});
});共享认证状态
typescript
// global-setup.ts
async function globalSetup() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// 登录一次
await page.goto('http://localhost:3000/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// 保存状态
await context.storageState({ path: 'auth.json' });
await browser.close();
}
// playwright.config.ts
export default {
globalSetup: require.resolve('./global-setup'),
use: {
storageState: 'auth.json', // 所有测试复用
},
};减少不必要的等待
typescript
// ✅ 好的实践
test('optimized test', async ({ page }) => {
// 同时进行导航和等待
await Promise.all([
page.waitForNavigation(),
page.click('a[href="/next"]'),
]);
// 使用合适的等待状态
await page.waitForLoadState('domcontentloaded'); // 而不是 'networkidle'
});
// ❌ 差的实践
test('slow test', async ({ page }) => {
await page.click('a[href="/next"]');
await page.waitForTimeout(2000); // 固定等待
await page.waitForLoadState('networkidle'); // 等待所有网络请求
});测试隔离
每个测试独立
typescript
// ✅ 好的实践
test.describe('User Management', () => {
test.beforeEach(async ({ page }) => {
// 每个测试都有干净的状态
await page.goto('/users');
});
test.afterEach(async ({ page }) => {
// 清理
await page.evaluate(() => localStorage.clear());
});
test('can create user', async ({ page }) => {
// 独立测试
});
test('can delete user', async ({ page }) => {
// 不依赖前一个测试
});
});
// ❌ 差的实践
test.describe('Dependent tests', () => {
let userId: string;
test('creates user', async ({ page }) => {
// 创建用户
userId = '123'; // 后续测试依赖这个变量
});
test('deletes user', async ({ page }) => {
// 依赖前一个测试
await deleteUser(userId);
});
});数据隔离
typescript
// 使用唯一标识符
test('creates unique user', async ({ page }) => {
const uniqueEmail = `test-${Date.now()}@example.com`;
await page.goto('/register');
await page.fill('[name="email"]', uniqueEmail);
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
});
// 使用测试数据库
test.beforeAll(async () => {
await setupTestDatabase();
});
test.afterAll(async () => {
await teardownTestDatabase();
});可维护性
有意义的测试名称
typescript
// ✅ 好的实践
describe('User Login', () => {
it('should redirect to dashboard after successful login', () => {});
it('should display error message for invalid credentials', () => {});
it('should remember user when "Remember me" is checked', () => {});
});
// ❌ 差的实践
describe('Login', () => {
it('test 1', () => {});
it('works', () => {});
it('login functionality', () => {});
});文档化复杂逻辑
typescript
test('processes complex order', async ({ page }) => {
// 步骤1: 添加多个商品到购物车
// 商品A: 标准商品
await page.goto('/products/standard-item');
await page.click('[data-testid="add-to-cart"]');
// 商品B: 需要定制的商品
await page.goto('/products/custom-item');
await page.fill('[name="customization"]', 'Special request');
await page.click('[data-testid="add-to-cart"]');
// 步骤2: 应用优惠券
// 注意: 优惠券只对商品A有效
await page.goto('/cart');
await page.fill('[name="coupon"]', 'DISCOUNT10');
await page.click('[data-testid="apply-coupon"]');
// 步骤3: 验证价格计算
// 期望: 商品A打9折,商品B原价
const total = await page.textContent('[data-testid="total"]');
expect(parseFloat(total.replace('$', ''))).toBeCloseTo(135.99, 2);
});版本控制
typescript
// 跟踪API版本变化
const API_VERSION = 'v2';
test('uses correct API version', async ({ page }) => {
await page.route(`/api/${API_VERSION}/users`, (route) => {
route.fulfill({ json: mockUsers });
});
});调试技巧
开发模式调试
typescript
// 使用headed模式
test('debug in browser', async ({ page }) => {
// npx playwright test --headed --debug
// 暂停执行
await page.pause();
// 慢速执行
await page.goto('/login', { timeout: 0 });
// 截图
await page.screenshot({ path: 'debug.png' });
});日志记录
typescript
test('logs debug info', async ({ page }) => {
// 启用日志
page.on('console', msg => console.log('Browser log:', msg.text()));
page.on('request', req => console.log('Request:', req.url()));
page.on('response', res => console.log('Response:', res.url(), res.status()));
await page.goto('/');
});追踪功能
typescript
// playwright.config.ts
export default {
use: {
trace: 'retain-on-failure', // 失败时保留追踪
},
};
// 查看追踪: npx playwright show-trace trace.zipCI/CD集成
GitHub Actions配置
yaml
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps ${{ matrix.browser }}
- name: Run tests
run: npx playwright test --project=${{ matrix.browser }}
env:
CI: true
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
retention-days: 30报告生成
typescript
// playwright.config.ts
export default {
reporter: [
['html', { outputFolder: 'playwright-report' }],
['junit', { outputFile: 'results.xml' }],
['json', { outputFile: 'results.json' }],
['list'], // 终端输出
],
};测试清单
测试编写清单
✅ 测试名称清晰描述测试目的
✅ 使用稳定的选择器(data-testid)
✅ 测试独立,不依赖其他测试
✅ 包含适当的断言
✅ 处理异步操作
✅ 清理测试数据
✅ 添加必要的注释
✅ 考虑边界条件
✅ 测试错误场景
✅ 避免硬编码等待代码审查清单
✅ Page Object使用正确
✅ 选择器集中管理
✅ 没有重复代码
✅ 等待策略合理
✅ 错误处理完善
✅ 测试数据使用工厂
✅ 性能优化得当
✅ 文档注释充分E2E测试是保证应用质量的重要手段。通过遵循这些最佳实践,你可以构建稳定、可靠、易维护的测试套件,为应用的持续交付提供坚实保障。
总结
编写高质量的E2E测试需要:
- 合理的测试策略和范围
- 清晰的代码组织结构
- 稳定的选择器策略
- 智能的等待机制
- 良好的错误处理
- 持续的性能优化
- 完善的测试隔离
- 易于维护的代码
- 有效的调试工具
- 完整的CI/CD集成
通过遵循这些最佳实践,可以显著提升E2E测试的质量和效率,为应用的稳定性和可靠性提供有力保障。