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 |