强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

09 - E2E 测试

09 - E2E 测试

使用浏览器自动化构建端到端测试体系,掌握页面对象模式、测试框架集成、CI/CD 与并行测试。


9.1 E2E 测试概述

端到端测试 (End-to-End Testing, E2E) 模拟真实用户操作,从用户界面到后端数据库的完整流程验证。

┌─────────────────────────────────────────────┐
│              测试金字塔                       │
│                                             │
│                 /  E2E  \      少量、慢速     │
│                /─────────\                   │
│               /  集成测试  \   中等数量        │
│              /──────────────\                │
│             /    单元测试     \  大量、快速     │
│            /──────────────────\              │
└────────────────────────────────────────────┘

E2E 测试的特点

特征说明
覆盖范围完整用户流程(UI → API → DB)
执行速度慢(秒/分钟级)
维护成本高(UI 变化导致测试失效)
置信度最高(接近真实用户行为)
失败原因多种可能(网络、服务、UI 变化)
建议数量占测试总量 10% 左右

9.2 测试框架选择

Playwright Test (推荐)

npm init playwright@latest
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ['html', { open: 'never' }],
    ['junit', { outputFile: 'results/junit.xml' }],
  ],
  use: {
    baseURL: process.env.BASE_URL || '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', use: { ...devices['Pixel 5'] } },
  ],
});

Selenium + pytest (Python)

pip install selenium pytest pytest-html pytest-xdist
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

@pytest.fixture(scope="session")
def driver():
    options = Options()
    options.add_argument("--headless=new")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")
    d = webdriver.Chrome(options=options)
    d.implicitly_wait(10)
    yield d
    d.quit()

@pytest.fixture
def base_url():
    return "http://localhost:3000"

Selenium + JUnit 5 (Java)

<!-- pom.xml -->
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.16.1</version>
</dependency>

9.3 页面对象模式 (Page Object Model)

POM 是 E2E 测试中最常用的设计模式——将页面元素和操作封装为独立的类。

设计理念

❌ 不使用 POM (硬编码)
─────────────────────
test_login():
    driver.find_element(By.ID, "username").send_keys("admin")
    driver.find_element(By.ID, "password").send_keys("pass")
    driver.find_element(By.CSS_SELECTOR, ".login-btn").click()
    assert "Dashboard" in driver.title

✅ 使用 POM
─────────────────────
test_login():
    login_page = LoginPage(driver)
    login_page.login("admin", "pass")
    assert login_page.is_on_dashboard()

Playwright — Page Object Model

// pages/LoginPage.js
class LoginPage {
  constructor(page) {
    this.page = page;
    this.usernameInput = page.getByLabel('用户名');
    this.passwordInput = page.getByLabel('密码');
    this.submitButton = page.getByRole('button', { name: '登录' });
    this.errorMessage = page.locator('.error-message');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(username, password) {
    await this.usernameInput.fill(username);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorMessage() {
    return await this.errorMessage.textContent();
  }
}

// pages/DashboardPage.js
class DashboardPage {
  constructor(page) {
    this.page = page;
    this.welcomeText = page.locator('.welcome-message');
    this.logoutButton = page.getByRole('button', { name: '退出' });
  }

  async isLoaded() {
    await this.welcomeText.waitFor({ state: 'visible' });
  }

  async getWelcomeText() {
    return await this.welcomeText.textContent();
  }

  async logout() {
    await this.logoutButton.click();
  }
}

module.exports = { LoginPage, DashboardPage };
// tests/login.spec.js
const { test, expect } = require('@playwright/test');
const { LoginPage } = require('../pages/LoginPage');
const { DashboardPage } = require('../pages/DashboardPage');

test.describe('登录功能', () => {
  test('正常登录', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('admin', 'password123');

    const dashboard = new DashboardPage(page);
    await dashboard.isLoaded();
    expect(await dashboard.getWelcomeText()).toContain('欢迎');
  });

  test('错误密码', async ({ page }) => {
    const loginPage = new LoginPage(page);
    await loginPage.goto();
    await loginPage.login('admin', 'wrong');

    expect(await loginPage.getErrorMessage()).toContain('错误');
  });
});

Selenium (Python) — Page Object Model

# pages/login_page.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

class LoginPage:
    URL = "/login"

    # 定位器
    _USERNAME = (By.ID, "username")
    _PASSWORD = (By.ID, "password")
    _SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
    _ERROR_MSG = (By.CSS_SELECTOR, ".error-message")

    def __init__(self, driver, base_url):
        self.driver = driver
        self.base_url = base_url
        self.wait = WebDriverWait(driver, 10)

    def goto(self):
        self.driver.get(f"{self.base_url}{self.URL}")
        return self

    def login(self, username, password):
        self.wait.until(EC.presence_of_element_located(self._USERNAME)).send_keys(username)
        self.driver.find_element(*self._PASSWORD).send_keys(password)
        self.driver.find_element(*self._SUBMIT).click()
        return self

    def get_error_message(self):
        return self.wait.until(
            EC.visibility_of_element_located(self._ERROR_MSG)
        ).text

    def is_on_dashboard(self):
        return "/dashboard" in self.driver.current_url
# tests/test_login.py
import pytest
from pages.login_page import LoginPage

class TestLogin:
    def test_successful_login(self, driver, base_url):
        login_page = LoginPage(driver, base_url)
        login_page.goto().login("admin", "password123")
        assert login_page.is_on_dashboard()

    def test_wrong_password(self, driver, base_url):
        login_page = LoginPage(driver, base_url)
        login_page.goto().login("admin", "wrong")
        assert "错误" in login_page.get_error_message()

POM 最佳实践

原则说明
一个页面一个类每个页面/组件对应一个 Page Object
不在 Page Object 中写断言断言放在测试代码中
方法返回 self 或新页面支持链式调用 (page.goto().login())
定位器集中管理定位器定义为类常量,便于维护
避免过度封装简单元素不需要单独方法

9.4 测试数据管理

夹具 (Fixture) 模式

// playwright fixtures
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  // 自定义 fixture: 已登录的页面
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('用户名').fill('admin');
    await page.getByLabel('密码').fill('password123');
    await page.getByRole('button', { name: '登录' }).click();
    await page.waitForURL('**/dashboard');
    await use(page);
  },

  // 自定义 fixture: 测试数据
  testData: async ({}, use) => {
    const data = {
      user: { username: 'testuser', email: '[email protected]' },
      product: { name: '测试商品', price: 99.99 },
    };
    await use(data);
  },
});

test('已登录用户可以访问设置', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/settings');
  await expect(authenticatedPage.locator('h1')).toHaveText('设置');
});

Python fixture

# conftest.py
import pytest

@pytest.fixture
def test_user():
    return {
        "username": "testuser",
        "password": "test123456",
        "email": "[email protected]",
    }

@pytest.fixture
def logged_in_driver(driver, base_url, test_user):
    """已登录的 driver"""
    login_page = LoginPage(driver, base_url)
    login_page.goto().login(test_user["username"], test_user["password"])
    yield driver

@pytest.fixture(autouse=True)
def cleanup(driver):
    """每个测试后清理 cookies"""
    yield
    driver.delete_all_cookies()

9.5 断言与验证

Playwright 断言

const { expect } = require('@playwright/test');

// 页面断言
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/.*dashboard/);

// 元素断言
await expect(page.locator('.status')).toBeVisible();
await expect(page.locator('.status')).toBeHidden();
await expect(page.locator('.item')).toHaveCount(5);
await expect(page.locator('.name')).toHaveText('张三');
await expect(page.locator('.name')).toContainText('张');
await expect(page.locator('.link')).toHaveAttribute('href', '/home');
await expect(page.locator('.link')).toHaveClass(/active/);
await expect(page.locator('input')).toHaveValue('hello');
await expect(page.locator('.list')).toHaveCSS('display', 'flex');

// 否定断言
await expect(page.locator('.error')).not.toBeVisible();
await expect(page.locator('.item')).not.toHaveCount(0);

// 自动等待: 断言会自动重试直到超时
// 默认超时 5000ms,可在配置中修改

Selenium + pytest 断言

import pytest

def test_page_title(driver, base_url):
    driver.get(base_url)
    assert "首页" in driver.title

def test_element_visible(driver, base_url):
    driver.get(base_url)
    element = WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.CSS_SELECTOR, ".header"))
    )
    assert element.is_displayed()

def test_element_text(driver, base_url):
    driver.get(f"{base_url}/profile")
    name = driver.find_element(By.CSS_SELECTOR, ".user-name").text
    assert name == "张三", f"期望 '张三',实际 '{name}'"

9.6 CI/CD 集成

GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        browser: [chromium, firefox, webkit]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install browsers
        run: npx playwright install --with-deps ${{ matrix.browser }}

      - name: Run E2E tests
        run: npx playwright test --project=${{ matrix.browser }}

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.browser }}
          path: |
            playwright-report/
            test-results/

GitLab CI

# .gitlab-ci.yml
stages:
  - test

e2e:
  stage: test
  image: mcr.microsoft.com/playwright:v1.40.0-jammy
  script:
    - npm ci
    - npx playwright test
  artifacts:
    when: always
    paths:
      - playwright-report/
      - test-results/
    expire: 7 days

Selenium + GitHub Actions (Python)

name: Selenium E2E

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install Chrome
        uses: browser-actions/setup-chrome@v1
        with:
          chrome-version: stable

      - name: Install dependencies
        run: |
          pip install selenium pytest pytest-html
          pip install webdriver-manager

      - name: Run tests
        run: pytest tests/ --html=report.html --self-contained-html

9.7 并行测试

Playwright Test 并行

// playwright.config.js
module.exports = defineConfig({
  fullyParallel: true,       // 所有测试并行运行
  workers: 4,                // 并行 worker 数
  retries: 2,                // 失败重试次数
});
// 文件级并行 (默认并行)
test.describe.parallel('并行测试组', () => {
  test('测试 A', async ({ page }) => { /* ... */ });
  test('测试 B', async ({ page }) => { /* ... */ });
});

// 文件级串行 (有依赖时)
test.describe.serial('串行测试组', () => {
  test('步骤 1', async ({ page }) => { /* ... */ });
  test('步骤 2', async ({ page }) => { /* ... */ }); // 依赖步骤 1
});

pytest-xdist 并行

pip install pytest-xdist

# 自动检测 CPU 核数
pytest tests/ -n auto

# 指定 worker 数
pytest tests/ -n 4

# 按文件分组 (避免同一文件内测试并行)
pytest tests/ -n auto --dist loadfile

并行测试注意事项

问题解决方案
测试数据冲突每个 worker 使用独立数据集
端口冲突使用随机端口或动态分配
共享状态测试之间不要共享 cookies/session
文件冲突每个 worker 独立的截图/报告目录
数据库锁使用事务回滚或独立数据库

9.8 测试报告

Playwright HTML 报告

// playwright.config.js
module.exports = defineConfig({
  reporter: [
    ['list'],                           // 控制台输出
    ['html', { open: 'never' }],        // HTML 报告
    ['junit', { outputFile: 'results.xml' }],  // JUnit XML
    ['json', { outputFile: 'results.json' }],   // JSON
  ],
});

pytest-html 报告

pip install pytest-html

pytest tests/ --html=report.html --self-contained-html

9.9 测试稳定性 (Flaky Tests)

Flaky tests 是 E2E 测试最大的敌人——非确定性的测试失败。

常见原因与解决

原因解决方案
元素未加载使用显式等待或自动等待
动画未结束等待动画完成或禁用动画
网络延迟增加超时或 mock 网络
数据依赖独立的测试数据和清理
时序问题避免 sleep,使用条件等待
浏览器状态每个测试独立 context
第三方服务Mock 外部依赖

减少 Flaky Tests 的策略

// ✅ 使用自动等待 (Playwright)
await page.click('#button');  // 自动等待元素可点击

// ❌ 避免硬编码等待
await page.waitForTimeout(3000);  // 不推荐

// ✅ 等待具体条件
await page.waitForSelector('.result', { state: 'visible' });

// ✅ 使用网络等待
await page.waitForResponse(resp => resp.url().includes('/api/'));

// ✅ 禁用动画
await page.addStyleTag({ content: '*, *::before, *::after { animation: none !important; transition: none !important; }' });
# Selenium — 避免 flaky 的策略
# ✅ 使用显式等待
element = WebDriverWait(driver, 10).until(
    EC.element_to_be_clickable((By.ID, "button"))
)

# ❌ 不要使用
time.sleep(3)
driver.implicitly_wait(5)  # 与显式等待混用

9.10 要点回顾

要点说明
POM 是基础模式页面对象封装元素和操作,测试代码只写业务逻辑
测试数据独立每个测试使用独立数据,避免相互影响
并行提高效率Playwright 和 pytest-xdist 都支持并行测试
CI 集成是必须每次提交自动运行 E2E 测试
稳定性优先消除 flaky tests 是 E2E 测试的持续工作
多浏览器覆盖至少覆盖 Chromium + Firefox + WebKit

9.11 注意事项

⚠️ 不要过度依赖 E2E 测试: E2E 测试慢且维护成本高,核心逻辑应优先用单元测试覆盖。

⚠️ 测试环境一致性: CI 和本地使用相同的浏览器版本,避免环境差异导致的失败。

⚠️ 测试隔离: 每个测试应该独立运行,不依赖其他测试的执行顺序或结果。

⚠️ 超时设置: CI 环境可能比本地慢,适当增加超时时间。


9.12 扩展阅读

资源链接
Playwright Test 文档https://playwright.dev/docs/test-intro
pytest 文档https://docs.pytest.org/
Selenium 测试实践https://www.selenium.dev/documentation/test_practices/
页面对象模式https://www.selenium.dev/documentation/test_practices/encouraged/page_object_models/
GitHub Actions 文档https://docs.github.com/en/actions
Flaky Tests 处理https://playwright.dev/docs/test-retries