关于 e2e-testing-patterns
e2e-testing-patterns 面向构建和维护端到端测试套件的开发者。它涵盖 Playwright 和 Cypress 模式,并提供可靠等待、API 模拟、视觉检查、并行测试与无障碍测试指南。
主要功能
- 01Playwright 与 Cypress 测试模式
- 02页面对象与可复用测试夹具
- 03条件等待与 API 模拟
- 04视觉回归与并行测试
- 05无障碍检查及不稳定测试排查
适用场景
- 01自动化关键用户流程
- 02排查不稳定测试
- 03进行跨浏览器测试
- 04检查无障碍与响应式表现
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ ovachiever/ droid-tings --skill e2e-testing-patterns安装后在 Claude Code 中输入/e2e-testing-patterns确认已加载。
详细介绍
端到端测试模式
构建可靠、快速且易于维护的端到端测试套件,帮助你放心快速发布代码,并在用户发现问题之前捕获回归。
何时使用此技能
- 实施端到端测试自动化
- 调试不稳定或不可靠的测试
- 测试关键用户工作流
- 设置 CI/CD 测试流水线
- 在多个浏览器中进行测试
- 验证无障碍要求
- 测试响应式设计
- 制定 E2E 测试标准
核心概念
1. E2E 测试基础
E2E 测试内容:
- 关键用户旅程(登录、结账、注册)
- 复杂交互(拖放、多步骤表单)
- 跨浏览器兼容性
- 真实 API 集成
- 身份验证流程
不应使用 E2E 测试的内容:
- 单元级逻辑(使用单元测试)
- API 契约(使用集成测试)
- 边缘情况(耗时过长)
- 内部实现细节
2. 测试理念
测试金字塔:
/\
/E2E\ ← Few, focused on critical paths
/─────\
/Integr\ ← More, test component interactions
/────────\
/Unit Tests\ ← Many, fast, isolated
/────────────\
最佳实践:
- 测试用户行为,而非实现方式
- 保持测试相互独立
- 确保测试具有确定性
- 优化测试速度
- 使用 data-testid,而非 CSS 选择器
Playwright 模式
设置与配置
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
expect: {
timeout: 5000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['junit', { outputFile: 'results.xml' }],
],
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', use: { ...devices['iPhone 13'] } },
],
});
模式 1:页面对象模型
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.loginButton = page.getByRole('button', { name: 'Login' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorMessage(): Promise<string> {
return await this.errorMessage.textContent() ?? '';
}
}
// Test using Page Object
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'password123');
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' }))
.toBeVisible();
});
test('failed login shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('[email protected]', 'wrong');
const error = await loginPage.getErrorMessage();
expect(error).toContain('Invalid credentials');
});
模式 2:用于测试数据的 Fixtures
// fixtures/test-data.ts
import { test as base } from '@playwright/test';
type TestData = {
testUser: {
email: string;
password: string;
name: string;
};
adminUser: {
email: string;
password: string;
};
};
export const test = base.extend<TestData>({
testUser: async ({}, use) => {
const user = {
email: `test-${Date.now()}@example.com`,
password: 'Test123!@#',
name: 'Test User',
};
// Setup: Create user in database
await createTestUser(user);
await use(user);
// Teardown: Clean up user
await deleteTestUser(user.email);
},
adminUser: async ({}, use) => {
await use({
email: '[email protected]',
password: process.env.ADMIN_PASSWORD!,
});
},
});
// Usage in tests
import { test } from './fixtures/test-data';
test('user can update profile', async ({ page, testUser }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(testUser.email);
await page.getByLabel('Password').fill(testUser.password);
await page.getByRole('button', { name: 'Login' }).click();
await page.goto('/profile');
await page.getByLabel('Name').fill('Updated Name');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Profile updated')).toBeVisible();
});
模式 3:等待策略
// ❌ Bad: Fixed timeouts
await page.waitForTimeout(3000); // Flaky!
// ✅ Good: Wait for specific conditions
await page.waitForLoadState('networkidle');
await page.waitForURL('/dashboard');
await page.waitForSelector('[data-testid="user-profile"]');
// ✅ Better: Auto-waiting with assertions
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByRole('button', { name: 'Submit' }))
.toBeEnabled();
// Wait for API response
const responsePromise = page.waitForResponse(
response => response.url().includes('/api/users') && response.status() === 200
);
await page.getByRole('button', { name: 'Load Users' }).click();
const response = await responsePromise;
const data = await response.json();
expect(data.users).toHaveLength(10);
// Wait for multiple conditions
await Promise.all([
page.waitForURL('/success'),
page.waitForLoadState('networkidle'),
expect(page.getByText('Payment successful')).toBeVisible(),
]);
模式 4:网络模拟与拦截
// Mock API responses
test('displays error when API fails', async ({ page }) => {
await page.route('**/api/users', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/users');
await expect(page.getByText('Failed to load users')).toBeVisible();
});
// Intercept and modify requests
test('can modify API request', async ({ page }) => {
await page.route('**/api/users', async route => {
const request = route.request();
const postData = JSON.parse(request.postData() || '{}');
// Modify request
postData.role = 'admin';
await route.continue({
postData: JSON.stringify(postData),
});
});
// Test continues...
});
// Mock third-party services
test('payment flow with mocked Stripe', async ({ page }) => {
await page.route('**/api/stripe/**', route => {
route.fulfill({
status: 200,
body: JSON.stringify({
id: 'mock_payment_id',
status: 'succeeded',
}),
});
});
// Test payment flow with mocked response
});
Cypress 模式
设置与配置
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
defaultCommandTimeout: 10000,
requestTimeout: 10000,
setupNodeEvents(on, config) {
// Implement node event listeners
},
},
});
模式 1:自定义命令
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
createUser(userData: UserData): Chainable<User>;
dataCy(value: string): Chainable<JQuery<HTMLElement>>;
}
}
}
Cypress.Commands.add('login', (email: string, password: string) => {
cy.visit('/login');
cy.get('[data-testid="email"]').type(email);
cy.get('[data-testid="password"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
});
Cypress.Commands.add('createUser', (userData: UserData) => {
return cy.request('POST', '/api/users', userData)
.its('body');
});
Cypress.Commands.add('dataCy', (value: string) => {
return cy.get(`[data-cy="${value}"]`);
});
// Usage
cy.login('[email protected]', 'password');
cy.dataCy('submit-button').click();
模式 2:Cypress 拦截
// Mock API calls
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
],
}).as('getUsers');
cy.visit('/users');
cy.wait('@getUsers');
cy.get('[data-testid="user-list"]').children().should('have.length', 2);
// Modify responses
cy.intercept('GET', '/api/users', (req) => {
req.reply((res) => {
// Modify response
res.body.users = res.body.users.slice(0, 5);
res.send();
});
});
// Simulate slow network
cy.intercept('GET', '/api/data', (req) => {
req.reply((res) => {
res.delay(3000); // 3 second delay
res.send();
});
});
高级模式
模式 1:视觉回归测试
// With Playwright
import { test, expect } from '@playwright/test';
test('homepage looks correct', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100,
});
});
test('button in all states', async ({ page }) => {
await page.goto('/components');
const button = page.getByRole('button', { name: 'Submit' });
// Default state
await expect(button).toHaveScreenshot('button-default.png');
// Hover state
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
// Disabled state
await button.evaluate(el => el.setAttribute('disabled', 'true'));
await expect(button).toHaveScreenshot('button-disabled.png');
});
模式 2:使用分片进行并行测试
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'shard-1',
use: { ...devices['Desktop Chrome'] },
grepInvert: /@slow/,
shard: { current: 1, total: 4 },
},
{
name: 'shard-2',
use: { ...devices['Desktop Chrome'] },
shard: { current: 2, total: 4 },
},
// ... more shards
],
});
// Run in CI
// npx playwright test --shard=1/4
// npx playwright test --shard=2/4
模式 3:无障碍测试
// Install: npm install @axe-core/playwright
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const accessibilityScanResults = await new AxeBuilder({ page })
.exclude('#third-party-widget')
.analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
test('form is accessible', async ({ page }) => {
await page.goto('/signup');
const results = await new AxeBuilder({ page })
.include('form')
.analyze();
expect(results.violations).toEqual([]);
});
最佳实践
- 使用数据属性:使用
data-testid或data-cy作为稳定的选择器 - 避免脆弱的选择器:不要依赖 CSS 类或 DOM 结构
- 测试用户行为:点击、输入、查看,而非实现细节
- 保持测试相互独立:每项测试都应独立运行
- 清理测试数据:在每项测试中创建并销毁测试数据
- 使用页面对象:封装页面逻辑
- 编写有意义的断言:检查用户实际可见的行为
- 优化测试速度:尽可能模拟,采用并行执行
// ❌ Bad selectors
cy.get('.btn.btn-primary.submit-button').click();
cy.get('div > form > div:nth-child(2) > input').type('text');
// ✅ Good selectors
cy.getByRole('button', { name: 'Submit' }).click();
cy.getByLabel('Email address').type('[email protected]');
cy.get('[data-testid="email-input"]').type('[email protected]');
常见陷阱
- 不稳定的测试:使用恰当的等待,而非固定超时
- 缓慢的测试:模拟外部 API,采用并行执行
- 过度测试:不要使用 E2E 测试覆盖每个边缘情况
- 相互耦合的测试:测试不应彼此依赖
- 糟糕的选择器:避免使用 CSS 类和 nth-child
- 未清理数据:每项测试后都要清理测试数据
- 测试实现方式:测试用户行为,而非内部细节
调试失败的测试
// Playwright debugging
// 1. Run in headed mode
npx playwright test --headed
// 2. Run in debug mode
npx playwright test --debug
// 3. Use trace viewer
await page.screenshot({ path: 'screenshot.png' });
await page.video()?.saveAs('video.webm');
// 4. Add test.step for better reporting
test('checkout flow', async ({ page }) => {
await test.step('Add item to cart', async () => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).click();
});
await test.step('Proceed to checkout', async () => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
});
});
// 5. Inspect page state
await page.pause(); // Pauses execution, opens inspector
资源
- references/playwright-best-practices.md:Playwright 专属模式
- references/cypress-best-practices.md:Cypress 专属模式
- references/flaky-test-debugging.md:调试不可靠的测试
- assets/e2e-testing-checklist.md:E2E 测试内容
- assets/selector-strategies.md:查找可靠选择器
- scripts/test-analyzer.ts:分析测试的不稳定性和耗时
评论
支持 Markdown