跳到主要内容

端到端测试(E2E Testing)

端到端测试(End-to-End Testing)是验证整个应用从开始到结束的完整流程是否正常工作的测试方法。它模拟真实用户场景,测试应用的所有组件协同工作的情况。

什么是端到端测试?

端到端测试从用户的角度出发,验证完整的业务流程。它测试的是整个系统,包括前端界面、后端服务、数据库、网络通信等所有环节。

端到端测试的特点

特点说明
真实场景模拟用户的实际操作流程
覆盖全面涉及前端、后端、数据库、网络等所有组件
执行缓慢需要启动完整环境,运行时间长
维护成本高界面变化容易导致测试失效
定位困难失败时难以确定具体问题所在

为什么需要端到端测试?

单元测试和集成测试虽然能验证代码的正确性,但它们无法保证整个系统协同工作时的正确性。端到端测试弥补了这一缺口:

  • 验证用户真实体验:从用户视角检查系统行为
  • 发现集成问题:揭示组件间交互的潜在问题
  • 保障业务流程:确保关键业务流程畅通无阻
  • 增强发布信心:发布前验证核心功能正常

测试金字塔中的位置

端到端测试位于测试金字塔的顶部,数量应该最少但价值最高。

建议比例:单元测试 70%、集成测试 20%、端到端测试 10%

E2E 测试工具对比

主流工具概览

工具语言特点适用场景
PlaywrightTypeScript/JavaScript/Python/C#/.NET跨浏览器、自动等待、内置调试现代 Web 应用
CypressJavaScript实时重载、时间旅行、简单易用前端团队
Selenium多语言生态成熟、社区庞大传统 Web 应用
PuppeteerJavaScriptChrome 官方、性能好Chrome 专项测试
TestCafeJavaScript无需浏览器插件、并发测试快速原型测试

Playwright vs Cypress

这是目前最热门的两个选择,各有优势:

选择建议

  • 需要多浏览器测试、多语言支持 → 选择 Playwright
  • 前端团队为主、追求快速上手 → 选择 Cypress
  • 大型项目、需要高性能并行测试 → 选择 Playwright
  • 中小型项目、重视调试体验 → 选择 Cypress

Playwright 实战

安装配置

# 创建项目
npm init playwright@latest

# 手动安装
npm install -D @playwright/test
npx playwright install

项目结构:

project/
├── playwright.config.ts # 配置文件
├── package.json
├── tests/
│ ├── login.spec.ts
│ ├── checkout.spec.ts
│ └── fixtures/
│ └── test-data.ts
└── test-results/

基础配置

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
// 测试目录
testDir: './tests',

// 全局测试配置
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,

// 报告配置
reporter: 'html',

// 全局设置
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: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// 移动端测试
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],

// 启动开发服务器
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

编写第一个测试

// tests/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('用户登录功能', () => {
test.beforeEach(async ({ page }) => {
// 每个测试前导航到登录页
await page.goto('/login');
});

test('应该成功登录', async ({ page }) => {
// 填写登录表单
await page.getByLabel('用户名').fill('testuser');
await page.getByLabel('密码').fill('password123');

// 点击登录按钮
await page.getByRole('button', { name: '登录' }).click();

// 验证登录成功
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.getByText('欢迎回来')).toBeVisible();
});

test('应该显示错误提示 - 密码错误', async ({ page }) => {
await page.getByLabel('用户名').fill('testuser');
await page.getByLabel('密码').fill('wrongpassword');
await page.getByRole('button', { name: '登录' }).click();

// 验证错误消息
await expect(page.getByText('用户名或密码错误')).toBeVisible();
});

test('应该验证必填字段', async ({ page }) => {
// 直接点击登录按钮,不填写任何内容
await page.getByRole('button', { name: '登录' }).click();

// 验证表单验证错误
await expect(page.getByText('请输入用户名')).toBeVisible();
await expect(page.getByText('请输入密码')).toBeVisible();
});
});

定位器(Locators)

Playwright 提供了多种定位元素的方式,推荐优先使用用户可见的属性:

import { test, expect } from '@playwright/test';

test('定位器示例', async ({ page }) => {
// 推荐方式:基于角色的定位(最稳定)
await page.getByRole('button', { name: '提交' }).click();
await page.getByRole('textbox', { name: '用户名' }).fill('test');
await page.getByRole('link', { name: '关于我们' }).click();

// 基于文本定位
await page.getByText('欢迎').click();
await page.getByText(/欢迎\s*回来/).click(); // 支持正则

// 基于标签定位
await page.getByLabel('邮箱地址').fill('[email protected]');

// 基于占位符定位
await page.getByPlaceholder('请输入搜索内容').fill('Playwright');

// 基于测试ID定位(需要团队约定)
await page.getByTestId('submit-button').click();

// 定位器链式调用
const product = page.getByRole('listitem')
.filter({ hasText: '商品A' });
await product.getByRole('button', { name: '加入购物车' }).click();

// 在特定区域内定位
const sidebar = page.getByTestId('sidebar');
await sidebar.getByRole('link', { name: '设置' }).click();
});

定位器选择优先级

  1. getByRole - 最稳定,基于可访问性角色
  2. getByText - 模拟用户阅读页面内容
  3. getByLabel - 基于表单标签
  4. getByPlaceholder - 基于输入框占位符
  5. getByTestId - 团队约定的测试ID(最后选择)

断言

Playwright 提供了 Web-First 断言,自动等待条件满足:

import { test, expect } from '@playwright/test';

test('断言示例', async ({ page }) => {
await page.goto('/products');

// 可见性断言
await expect(page.getByRole('heading', { name: '商品列表' })).toBeVisible();

// 文本内容断言
await expect(page.getByTestId('product-name')).toHaveText('商品A');
await expect(page.getByTestId('price')).toContainText('99');

// 数值断言
await expect(page.getByTestId('cart-count')).toHaveText('3');

// 属性断言
await expect(page.getByTestId('email-input')).toHaveAttribute('type', 'email');
await expect(page.getByRole('button', { name: '提交' })).toBeEnabled();
await expect(page.getByRole('checkbox')).toBeChecked();

// 数量断言
await expect(page.getByRole('listitem')).toHaveCount(5);

// CSS类断言
await expect(page.getByTestId('alert')).toHaveClass(/error/);

// 截图比对(视觉回归测试)
await expect(page).toHaveScreenshot();

// URL断言
await expect(page).toHaveURL(/.*dashboard/);

// 页面标题断言
await expect(page).toHaveTitle(/我的应用/);

// 软断言(不中断测试)
await expect.soft(page.getByTestId('status')).toHaveText('成功');
// 即使上面的断言失败,测试也会继续执行
});

处理用户交互

import { test, expect } from '@playwright/test';

test('用户交互示例', async ({ page }) => {
await page.goto('/forms');

// 输入文本
await page.getByLabel('姓名').fill('张三');

// 清空并输入
await page.getByLabel('邮箱').clear();
await page.getByLabel('邮箱').fill('[email protected]');

// 点击和双击
await page.getByRole('button', { name: '保存' }).click();
await page.getByTestId('item').dblclick();

// 复选框和单选框
await page.getByLabel('同意条款').check();
await page.getByLabel('接收通知').uncheck();
await page.getByLabel('男').check(); // 单选框

// 下拉选择
await page.getByLabel('城市').selectOption('beijing');
await page.getByLabel('技能').selectOption(['javascript', 'python']); // 多选

// 文件上传
await page.getByLabel('头像').setInputFiles('tests/fixtures/avatar.png');
await page.getByLabel('文档').setInputFiles(['file1.pdf', 'file2.pdf']);

// 键盘操作
await page.getByLabel('搜索').press('Enter');
await page.getByLabel('搜索').press('Control+A'); // 全选
await page.keyboard.press('Escape');

// 鼠标操作
await page.getByTestId('menu-item').hover();
await page.mouse.click(100, 200); // 坐标点击

// 拖拽
await page.getByTestId('draggable').dragTo(page.getByTestId('dropzone'));

// 滚动
await page.getByTestId('long-list').scrollIntoViewIfNeeded();
await page.mouse.wheel(0, 500); // 向下滚动
});

处理弹窗和对话框

import { test, expect } from '@playwright/test';

test('弹窗处理', async ({ page }) => {
// 监听并处理 alert/confirm/prompt
page.on('dialog', async dialog => {
console.log(`对话框消息: ${dialog.message()}`);

if (dialog.type() === 'alert') {
await dialog.accept();
} else if (dialog.type() === 'confirm') {
await dialog.accept(); // 点击确定
// await dialog.dismiss(); // 点击取消
} else if (dialog.type() === 'prompt') {
await dialog.accept('输入内容'); // 输入并确定
}
});

// 触发弹窗
await page.getByRole('button', { name: '删除' }).click();
});

test('处理新窗口', async ({ page, context }) => {
// 监听新页面
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: '在新窗口打开' }).click(),
]);

// 在新窗口中操作
await expect(newPage).toHaveURL(/.*external/);
await newPage.getByRole('button', { name: '确认' }).click();

// 关闭新窗口
await newPage.close();
});

网络请求处理

import { test, expect } from '@playwright/test';

test('模拟 API 响应', async ({ page }) => {
// 拦截并模拟 API 响应
await page.route('**/api/users', async route => {
const json = {
users: [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
]
};
await route.fulfill({ json });
});

await page.goto('/users');
await expect(page.getByRole('listitem')).toHaveCount(2);
});

test('修改请求', async ({ page }) => {
// 添加请求头
await page.route('**/api/*', async route => {
const headers = {
...route.request().headers(),
'Authorization': 'Bearer test-token',
};
await route.continue({ headers });
});

await page.goto('/dashboard');
});

test('模拟网络错误', async ({ page }) => {
// 模拟请求失败
await page.route('**/api/data', route => route.abort('failed'));

await page.goto('/data');
await expect(page.getByText('加载失败')).toBeVisible();
});

test('模拟慢速网络', async ({ page }) => {
// 模拟延迟响应
await page.route('**/api/slow', async route => {
await new Promise(resolve => setTimeout(resolve, 5000));
await route.continue();
});

await page.goto('/slow-page');
});

test('等待请求完成', async ({ page }) => {
// 等待特定请求
const responsePromise = page.waitForResponse('**/api/submit');

await page.getByRole('button', { name: '提交' }).click();

const response = await responsePromise;
expect(response.status()).toBe(200);

const data = await response.json();
expect(data.success).toBe(true);
});

测试夹具(Fixtures)

使用夹具可以在测试间共享设置逻辑:

// tests/fixtures/auth.ts
import { test as base, expect } from '@playwright/test';

// 定义夹具类型
type AuthFixtures = {
loggedInPage: { page: Page; user: User };
};

// 扩展 test 对象
export const test = base.extend<AuthFixtures>({
loggedInPage: async ({ page }, use) => {
// 设置:登录用户
await page.goto('/login');
await page.getByLabel('用户名').fill('testuser');
await page.getByLabel('密码').fill('password123');
await page.getByRole('button', { name: '登录' }).click();
await expect(page).toHaveURL(/.*dashboard/);

// 使用夹具
await use({
page,
user: { id: 1, username: 'testuser' }
});

// 清理(可选)
},
});

// 使用夹具的测试
test('用户可以查看订单', async ({ loggedInPage }) => {
const { page, user } = loggedInPage;

await page.getByRole('link', { name: '我的订单' }).click();
await expect(page.getByRole('heading', { name: '订单列表' })).toBeVisible();
});

Page Object 模式

对于大型项目,使用 Page Object 模式组织代码:

// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;

constructor(page: Page) {
this.page = page;
this.usernameInput = page.getByLabel('用户名');
this.passwordInput = page.getByLabel('密码');
this.loginButton = page.getByRole('button', { name: '登录' });
this.errorMessage = page.getByTestId('error-message');
}

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

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

async expectError(message: string) {
await expect(this.errorMessage).toHaveText(message);
}
}
// tests/pages/DashboardPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class DashboardPage {
readonly page: Page;
readonly welcomeMessage: Locator;
readonly ordersLink: Locator;

constructor(page: Page) {
this.page = page;
this.welcomeMessage = page.getByText('欢迎回来');
this.ordersLink = page.getByRole('link', { name: '我的订单' });
}

async expectLoggedIn() {
await expect(this.welcomeMessage).toBeVisible();
}

async goToOrders() {
await this.ordersLink.click();
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { DashboardPage } from './pages/DashboardPage';

test('完整登录流程', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);

// 导航到登录页
await loginPage.goto();

// 执行登录
await loginPage.login('testuser', 'password123');

// 验证登录成功
await dashboardPage.expectLoggedIn();
});

test('登录失败显示错误', async ({ page }) => {
const loginPage = new LoginPage(page);

await loginPage.goto();
await loginPage.login('wrong', 'wrong');

await loginPage.expectError('用户名或密码错误');
});

视觉回归测试

Playwright 支持截图比对,用于视觉回归测试:

import { test, expect } from '@playwright/test';

test('首页视觉测试', async ({ page }) => {
await page.goto('/');

// 整页截图比对
await expect(page).toHaveScreenshot('homepage.png');

// 特定元素截图比对
const header = page.getByTestId('header');
await expect(header).toHaveScreenshot('header.png');

// 设置容差
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100, // 允许100个像素差异
maxDiffPixelRatio: 0.01, // 允许1%的差异比例
});

// 屏蔽动态内容
await expect(page).toHaveScreenshot('homepage.png', {
mask: [page.getByTestId('dynamic-banner')],
});
});

Cypress 实战

安装配置

# 安装 Cypress
npm install -D cypress

# 初始化
npx cypress open

基础测试

// cypress/e2e/login.cy.js
describe('用户登录功能', () => {
beforeEach(() => {
cy.visit('/login');
});

it('应该成功登录', () => {
cy.get('[data-cy=username]').type('testuser');
cy.get('[data-cy=password]').type('password123');
cy.get('[data-cy=login-button]').click();

cy.url().should('include', '/dashboard');
cy.contains('欢迎回来').should('be.visible');
});

it('应该显示错误提示', () => {
cy.get('[data-cy=username]').type('wrong');
cy.get('[data-cy=password]').type('wrong');
cy.get('[data-cy=login-button]').click();

cy.contains('用户名或密码错误').should('be.visible');
});
});

Cypress 特有功能

// 时间旅行调试
it('时间旅行示例', () => {
cy.visit('/shopping-cart');

// Cypress 会记录每一步的快照
cy.get('.product').first().click();
cy.get('.add-to-cart').click();
cy.get('.cart-count').should('equal', '1');

// 在测试运行器中可以回放每一步
});

// 实时重载
// 修改测试文件后,Cypress 会自动重新运行测试

// 命令日志
it('命令日志示例', () => {
cy.log('开始测试');
cy.visit('/').then(() => {
cy.log('页面加载完成');
});
});

E2E 测试最佳实践

1. 测试用户可见的行为

测试应该关注用户能看到和交互的内容,而不是实现细节:

// ❌ 不好:依赖实现细节
await page.locator('.btn-primary.btn-large.submit').click();
await expect(page.locator('.success-message')).toBeVisible();

// ✅ 好:使用用户可见的属性
await page.getByRole('button', { name: '提交订单' }).click();
await expect(page.getByText('订单提交成功')).toBeVisible();

2. 保持测试独立

每个测试应该独立运行,不依赖其他测试的结果:

// ❌ 不好:测试相互依赖
let userId;

test('创建用户', async ({ page }) => {
await page.goto('/users/new');
await page.getByLabel('姓名').fill('张三');
await page.getByRole('button', { name: '保存' }).click();
userId = await page.getByTestId('user-id').textContent();
});

test('查看用户', async ({ page }) => {
await page.goto(`/users/${userId}`); // 依赖上一个测试
});

// ✅ 好:每个测试独立
test('创建并查看用户', async ({ page }) => {
// 创建用户
await page.goto('/users/new');
await page.getByLabel('姓名').fill('张三');
await page.getByRole('button', { name: '保存' }).click();

// 获取用户ID
const userId = await page.getByTestId('user-id').textContent();

// 查看用户
await page.goto(`/users/${userId}`);
await expect(page.getByText('张三')).toBeVisible();
});

3. 避免测试第三方依赖

不要测试你无法控制的外部服务:

// ❌ 不好:测试真实的支付网关
await page.goto('/checkout');
await page.getByLabel('信用卡号').fill('4111111111111111');
await page.getByRole('button', { name: '支付' }).click();

// ✅ 好:模拟支付网关响应
await page.route('**/api/payment', async route => {
await route.fulfill({
json: { status: 'success', transactionId: 'txn_123' }
});
});

await page.goto('/checkout');
await page.getByLabel('信用卡号').fill('4111111111111111');
await page.getByRole('button', { name: '支付' }).click();
await expect(page.getByText('支付成功')).toBeVisible();

4. 使用合适的等待策略

// ❌ 不好:使用固定等待
await page.waitForTimeout(3000);
await page.getByRole('button').click();

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

// ✅ 好:等待特定条件
await expect(page.getByText('加载完成')).toBeVisible();
await page.waitForResponse('**/api/data');
await page.waitForLoadState('networkidle');

5. 合理组织测试数据

// 使用 API 预设测试数据,而不是通过 UI
test('订单列表显示用户订单', async ({ page, request }) => {
// 通过 API 创建测试数据
await request.post('/api/orders', {
data: {
userId: 1,
items: [{ productId: 100, quantity: 2 }]
}
});

// 直接访问订单页面测试
await page.goto('/orders');
await expect(page.getByRole('listitem')).toHaveCount(1);
});

6. 处理异步操作

test('异步加载示例', async ({ page }) => {
await page.goto('/search');

// 等待多个条件
await Promise.all([
page.waitForResponse('**/api/search'),
page.getByLabel('搜索').fill('Playwright'),
page.getByRole('button', { name: '搜索' }).click(),
]);

// 验证结果
await expect(page.getByTestId('results')).toBeVisible();
});

CI/CD 集成

GitHub Actions 配置

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

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

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps

- name: Run E2E tests
run: npx playwright test

- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

并行测试配置

// playwright.config.ts
export default defineConfig({
// 在 CI 上使用分片
workers: process.env.CI ? 1 : 4,

// 分片配置(在多台机器上并行运行)
// CI: npx playwright test --shard=1/3
// CI: npx playwright test --shard=2/3
// CI: npx playwright test --shard=3/3
});

常见问题解决

1. 元素找不到

// 检查元素是否存在
const button = page.getByRole('button', { name: '提交' });
const isVisible = await button.isVisible();
if (!isVisible) {
console.log('按钮不可见,检查页面状态');
}

// 使用更宽松的选择器
await page.getByRole('button').first().click();

2. 超时问题

// 增加单个测试的超时时间
test('慢速测试', async ({ page }) => {
test.setTimeout(60000); // 60秒

await page.goto('/slow-page');
}, 60000); // 或在这里设置

// 增加断言等待时间
await expect(page.getByText('完成')).toBeVisible({ timeout: 10000 });

3. 测试不稳定

// 使用重试机制
test('不稳定测试', async ({ page }) => {
// ...
}, { retry: 3 }); // 失败后重试3次

// 改进等待策略
await page.waitForLoadState('domcontentloaded'); // 更快的等待
// 而不是
await page.waitForLoadState('networkidle'); // 等待所有网络请求完成

总结

端到端测试是确保系统整体质量的重要手段。关键要点:

  • 控制数量:E2E 测试运行慢,只覆盖关键业务流程
  • 用户视角:测试用户可见的行为,不依赖实现细节
  • 保持独立:每个测试独立运行,不依赖其他测试
  • 合理等待:使用自动等待,避免固定超时
  • 持续维护:界面变化时及时更新测试用例

参考资源