Skip to content

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.zip

CI/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测试的质量和效率,为应用的稳定性和可靠性提供有力保障。