端到端测试(E2E Testing)
端到端测试(End-to-End Testing)是验证整个应用从开始到结束的完整流程是否正常工作的测试方法。它模拟真实用户场景,测试应用的所有组件协同工作的情况。
什么是端到端测试?
端到端测试从用户的角度出发,验证完整的业务流程。它测试的是整个系统,包括前端界面、后端服务、数据库、网络通信等所有环节。
端到端测试的特点
| 特点 | 说明 |
|---|---|
| 真实场景 | 模拟用户的实际操作流程 |
| 覆盖全面 | 涉及前端、后端、数据库、网络等所有组件 |
| 执行缓慢 | 需要启动完整环境,运行时间长 |
| 维护成本高 | 界面变化容易导致测试失效 |
| 定位困难 | 失败时难以确定具体问题所在 |
为什么需要端到端测试?
单元测试和集成测试虽然能验证代码的正确性,但它们无法保证整个系统协同工作时的正确性。端到端测试弥补了这一缺口:
- 验证用户真实体验:从用户视角检查系统行为
- 发现集成问题:揭示组件间交互的潜在问题
- 保障业务流程:确保关键业务流程畅通无阻
- 增强发布信心:发布前验证核心功能正常
测试金字塔中的位置
端到端测试位于测试金字塔的顶部,数量应该最少但价值最高。
建议比例:单元测试 70%、集成测试 20%、端到端测试 10%
E2E 测试工具对比
主流工具概览
| 工具 | 语言 | 特点 | 适用场景 |
|---|---|---|---|
| Playwright | TypeScript/JavaScript/Python/C#/.NET | 跨浏览器、自动等待、内置调试 | 现代 Web 应用 |
| Cypress | JavaScript | 实时重载、时间旅行、简单易用 | 前端团队 |
| Selenium | 多语言 | 生态成熟、社区庞大 | 传统 Web 应用 |
| Puppeteer | JavaScript | Chrome 官方、性能好 | Chrome 专项测试 |
| TestCafe | JavaScript | 无需浏览器插件、并发测试 | 快速原型测试 |
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();
});
定位器选择优先级:
- getByRole - 最稳定,基于可访问性角色
- getByText - 模拟用户阅读页面内容
- getByLabel - 基于表单标签
- getByPlaceholder - 基于输入框占位符
- 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 测试运行慢,只覆盖关键业务流程
- 用户视角:测试用户可见的行为,不依赖实现细节
- 保持独立:每个测试独立运行,不依赖其他测试
- 合理等待:使用自动等待,避免固定超时
- 持续维护:界面变化时及时更新测试用例