Skip to content

E2E 测试 (Playwright/Cypress)

概述

E2E(端到端)测试模拟真实用户场景,验证整个应用流程的正确性。

一、框架对比

特性PlaywrightCypress
浏览器支持Chromium, Firefox, WebKitChrome, 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 open
javascript
// 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 测试稳定性?

:

  1. 使用语义化定位器,避免 CSS/XPath
  2. 等待明确的 UI 状态,避免 sleep
  3. 使用 Mock API 隔离后端不稳定性
  4. 重试机制处理偶发失败
  5. 并行执行时确保测试隔离

Q4: 如何处理测试数据?

:

  1. Fixtures: 静态测试数据文件
  2. Factory: 动态生成测试数据
  3. API Setup: beforeEach 中通过 API 创建数据
  4. 数据库 Seed: 测试前重置数据库

前端面试知识库