名称: e2e-testing 描述: 编写全面的端到端测试,以验证完整的用户工作流、组件之间的集成以及从用户视角的系统行为,使用Playwright、Cypress或类似框架。用于测试用户流程、验证功能集成、跨真实浏览器测试、确保UI交互正确、验证表单提交端到端、测试认证流程、捕获回归、自动化QA过程、测试API集成或构建生产部署的信心。—
端到端测试 - 测试完整用户工作流
何时使用此技能
- 测试完整的用户工作流端到端
- 验证前端和后端之间的集成
- 测试关键用户旅程(注册、结账等)
- 验证表单提交和数据持久化
- 测试认证和授权流程
- 捕获生产部署前的回归
- 自动化手动QA测试过程
- 测试跨设备的响应行为
- 验证第三方集成正常工作
- 通过自动化测试构建CI/CD信心
- 测试错误处理和边缘情况
- 确保可访问性功能正确工作
何时使用此技能
- 验证完整的用户流程、测试集成、验证关键路径,或确保功能在生产类似环境中工作。
- 当处理相关任务或功能时
- 在需要此专业知识的开发过程中
使用时机: 验证完整的用户流程、测试集成、验证关键路径,或确保功能在生产类似环境中工作。
核心原则
- 测试用户旅程,而非实现 - 关注用户做什么
- 优先测试关键路径 - 快乐路径和关键收入流
- 稳定选择器 - 使用data-testid,而非脆弱的CSS选择器
- 独立测试 - 测试之间不共享状态
- 快速反馈 - 并行执行,智能重试
Playwright(推荐)
1. 基本设置
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
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'
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] }
}
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
});
2. 编写测试
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('认证', () => {
test('用户可以注册', async ({ page }) => {
await page.goto('/signup');
// 填写表单
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('SecurePass123!');
await page.getByLabel('Confirm Password').fill('SecurePass123!');
// 提交
await page.getByRole('button', { name: 'Sign Up' }).click();
// 断言成功
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome')).toBeVisible();
});
test('对无效邮箱显示错误', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill('invalid-email');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign Up' }).click();
// 错误消息出现
await expect(page.getByText('Invalid email format')).toBeVisible();
// 仍在注册页面
await expect(page).toHaveURL('/signup');
});
test('用户可以登录', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('existing@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log In' }).click();
await expect(page).toHaveURL('/dashboard');
});
});
3. 页面对象模型
// ✅ 封装页面交互
// e2e/pages/LoginPage.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Log In' }).click();
}
async getErrorMessage() {
return this.page.getByRole('alert').textContent();
}
}
// 测试中的使用
test('成功登录', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('user@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
4. 用于设置/清理的夹具
// ✅ 可重用的测试夹具
// e2e/fixtures.ts
import { test as base } from '@playwright/test';
type Fixtures = {
authenticatedPage: Page;
};
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
// 设置:测试前登录
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log In' }).click();
await page.waitForURL('/dashboard');
// 运行测试
await use(page);
// 清理:测试后登出
await page.getByRole('button', { name: 'Log Out' }).click();
}
});
// 使用
test('作为认证用户创建帖子', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/posts/new');
await authenticatedPage.getByLabel('Title').fill('My Post');
await authenticatedPage.getByRole('button', { name: 'Publish' }).click();
await expect(authenticatedPage.getByText('Post published')).toBeVisible();
});
5. 等待策略
// ✅ 等待条件,而非任意超时
test('搜索功能', async ({ page }) => {
await page.goto('/search');
// 输入搜索
await page.getByPlaceholder('Search...').fill('playwright');
// ❌ 糟糕 - 任意超时
await page.waitForTimeout(2000);
// ✅ 良好 - 等待特定条件
await page.waitForLoadState('networkidle');
// ✅ 更好 - 等待特定元素
await page.waitForSelector('[data-testid="search-results"]');
// ✅ 最佳 - 隐式等待与断言
await expect(page.getByTestId('search-results')).toBeVisible();
const results = page.getByTestId('result-item');
await expect(results).toHaveCount(10);
});
// ✅ 等待API调用
test('数据正确加载', async ({ page }) => {
// 等待特定API调用
const responsePromise = page.waitForResponse(
(response) => response.url().includes('/api/users') && response.status() === 200
);
await page.goto('/users');
const response = await responsePromise;
const data = await response.json();
expect(data.users).toHaveLength(5);
});
6. 测试表单与交互
test('结账流程', async ({ page }) => {
await page.goto('/cart');
// 点击结账按钮
await page.getByRole('button', { name: 'Checkout' }).click();
// 填写运送信息
await page.getByLabel('Full Name').fill('John Doe');
await page.getByLabel('Address').fill('123 Main St');
await page.getByLabel('City').fill('San Francisco');
await page.getByLabel('State').selectOption('CA');
await page.getByLabel('ZIP Code').fill('94102');
await page.getByRole('button', { name: 'Continue' }).click();
// 填写支付信息(使用测试卡)
await page.frameLocator('iframe[title="Payment"]')
.getByPlaceholder('Card number')
.fill('4242424242424242');
await page.frameLocator('iframe[title="Payment"]')
.getByPlaceholder('MM / YY')
.fill('12/25');
await page.frameLocator('iframe[title="Payment"]')
.getByPlaceholder('CVC')
.fill('123');
// 提交订单
await page.getByRole('button', { name: 'Place Order' }).click();
// 验证成功
await expect(page.getByText('Order confirmed')).toBeVisible();
await expect(page).toHaveURL(/\/orders\/\d+/);
});
7. 测试文件上传
test('上传个人资料图片', async ({ page }) => {
await page.goto('/profile/edit');
// 上传文件
const fileInput = page.getByLabel('Profile Picture');
await fileInput.setInputFiles('fixtures/avatar.png');
// 等待上传完成
await expect(page.getByText('Upload complete')).toBeVisible();
await page.getByRole('button', { name: 'Save' }).click();
// 验证新头像显示
const avatar = page.getByRole('img', { name: 'Profile picture' });
await expect(avatar).toHaveAttribute('src', /avatar\.png/);
});
8. 视觉回归测试
test('主页看起来正确', async ({ page }) => {
await page.goto('/');
// 截取屏幕截图并比较
await expect(page).toHaveScreenshot('homepage.png');
});
test('按钮悬停状态', async ({ page }) => {
await page.goto('/');
const button = page.getByRole('button', { name: 'Get Started' });
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
});
Cypress 替代方案
1. Cypress 设置
// cypress/e2e/auth.cy.ts
describe('认证', () => {
beforeEach(() => {
cy.visit('/login');
});
it('允许用户登录', () => {
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('password123');
cy.get('[data-testid="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome back').should('be.visible');
});
it('对错误密码显示错误', () => {
cy.get('[data-testid="email"]').type('user@example.com');
cy.get('[data-testid="password"]').type('wrongpassword');
cy.get('[data-testid="submit"]').click();
cy.contains('Invalid credentials').should('be.visible');
});
});
2. Cypress 命令
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
createPost(title: string, content: string): Chainable<void>;
}
}
}
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="submit"]').click();
cy.url().should('include', '/dashboard');
});
Cypress.Commands.add('createPost', (title, content) => {
cy.visit('/posts/new');
cy.get('[data-testid="title"]').type(title);
cy.get('[data-testid="content"]').type(content);
cy.get('[data-testid="publish"]').click();
});
// 使用
it('创建帖子', () => {
cy.login('user@example.com', 'password123');
cy.createPost('My Post', 'Post content here');
cy.contains('Post published').should('be.visible');
});
最佳实践
1. 使用稳定选择器
// ❌ 脆弱 - 样式改变时失效
await page.locator('.btn-primary.large').click();
await page.locator('div > div > button:nth-child(2)').click();
// ✅ 语义 - 使用可访问角色
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com');
// ✅ 测试ID - 显式测试钩子
await page.getByTestId('submit-button').click();
await page.getByTestId('email-input').fill('test@example.com');
// 在组件中:
<button data-testid="submit-button">Submit</button>
<input data-testid="email-input" type="email" />
2. 独立测试
// ❌ 测试相互依赖
test('创建用户', async ({ page }) => {
// 创建用户,ID存储在全局
globalUserId = await createUser();
});
test('更新用户', async ({ page }) => {
// 如果前一个测试未运行,则失败
await updateUser(globalUserId);
});
// ✅ 每个测试自包含
test('更新用户', async ({ page }) => {
// 仅为此测试创建用户
const user = await createTestUser();
await page.goto(`/users/${user.id}/edit`);
await page.getByLabel('Name').fill('New Name');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Updated successfully')).toBeVisible();
// 清理
await deleteTestUser(user.id);
});
3. 测试数据管理
// ✅ 用于测试数据的工厂函数
export function createTestUser(overrides = {}) {
return {
email: `test-${Date.now()}@example.com`,
password: 'password123',
name: 'Test User',
...overrides
};
}
// 使用
test('创建多个用户', async ({ page }) => {
const user1 = createTestUser({ name: 'Alice' });
const user2 = createTestUser({ name: 'Bob' });
// 每个都有唯一邮箱,由于时间戳
});
// ✅ 数据库播种
test.beforeEach(async ({ page }) => {
// 使用测试数据播种数据库
await db.users.deleteMany();
await db.users.createMany([
createTestUser({ email: 'alice@test.com' }),
createTestUser({ email: 'bob@test.com' })
]);
});
4. 模拟外部服务
// ✅ 模拟API响应
test('处理API错误', async ({ page }) => {
// 拦截和模拟API调用
await page.route('**/api/users', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server error' })
});
});
await page.goto('/users');
await expect(page.getByText('Failed to load users')).toBeVisible();
});
// ✅ 模拟成功响应
test('显示用户列表', async ({ page }) => {
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
users: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]
})
});
});
await page.goto('/users');
await expect(page.getByText('Alice')).toBeVisible();
await expect(page.getByText('Bob')).toBeVisible();
});
E2E 测试检查表
测试覆盖:
□ 关键用户路径测试(注册、登录、购买)
□ 快乐路径覆盖
□ 错误场景测试
□ 边缘情况包括
□ 移动视口测试
测试质量:
□ 稳定选择器(data-testid、角色)
□ 无任意超时
□ 无测试依赖
□ 对异步操作的正确等待
□ 清晰的测试描述
性能:
□ 测试并行运行
□ 快速测试数据创建
□ 最小化不必要等待
□ 模拟的strategic使用
□ 测试后清理
CI/CD:
□ 每个PR运行测试
□ 失败阻止合并
□ 失败时截图
□ 测试报告生成
□ 识别和修复不稳定的测试
资源
记住:E2E测试验证用户实际体验。保持它们专注于关键路径、稳定且快速。