Implement robust E2E tests with Playwright or Cypress using Page Object Model, proper waits, and CI/CD integration. Covers selector strategies, flaky test prevention, and cross-browser testing patterns.
End-to-end testing validates complete user workflows across the entire application stack, simulating real user interactions to ensure system reliability.
| Feature | Playwright | Cypress | |---------|-----------|---------| | Browsers | Chromium, Firefox, WebKit | Chromium (Firefox experimental) | | Auto-waiting | Built-in | Built-in | | Debugging | Trace viewer | Time-travel | | Best for | Cross-browser, complex scenarios | Rapid development, visual debugging |
// Page Object
class LoginPage {
constructor(private page: Page) {}
async login(username: string, password: string) {
await this.page.fill('[data-testid="username"]', username);
await this.page.fill('[data-testid="password"]', password);
await this.page.click('[data-testid="login-button"]');
}
}
// Test
test('user can login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});
Selectors (Priority Order)
data-testid attributes (most reliable)Waits
sleep() or wait(ms)Test Data
npm init playwright@latest
Project Structure
tests/
e2e/
auth/login.spec.ts
fixtures/test-data.ts
page-objects/
LoginPage.ts
BasePage.ts
playwright.config.ts
Configuration
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['html'], ['junit', { outputFile: 'test-results/junit.xml' }]],
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: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
npm install --save-dev cypress
npx cypress open
Configuration
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
},
retries: { runMode: 2, openMode: 0 },
});
import { Page } from '@playwright/test';
export abstract class BasePage {
constructor(protected page: Page) {}
async navigate(path: string = '') {
await this.page.goto(path);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
}
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.usernameInput = page.getByTestId('username-input');
this.passwordInput = page.getByTestId('password-input');
this.loginButton = page.getByRole('button', { name: 'Log in' });
this.errorMessage = page.getByTestId('error-message');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async getErrorText(): Promise<string> {
return await this.errorMessage.textContent() || '';
}
}
// 1. Test IDs (Recommended)
await page.getByTestId('submit-button').click();
cy.get('[data-testid="submit-button"]').click();
// 2. Accessibility Attributes
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email address').fill('user@example.com');
// 3. Text Content
await page.getByText('Welcome back').click();
cy.contains('Welcome back').click();
// 4. CSS Selectors (Last Resort)
await page.locator('.submit-btn.primary').click();
// Good: Resilient to UI changes
await page.getByRole('navigation').getByRole('link', { name: 'Products' });
// Bad: Brittle, breaks with styling changes
await page.locator('#nav > ul > li:nth-child(2) > a');
// Good: Semantic and accessible
await page.getByLabel('Search').fill('laptops');
// Bad: Depends on implementation details
await page.locator('input[name="q"][type="text"]').fill('laptops');
Both frameworks automatically wait for elements to be:
// No explicit wait needed
await page.click('button'); // Waits for button to be clickable
cy.get('button').click(); // Waits for button to be clickable
// Wait for element state
await page.waitForSelector('[data-testid="results"]', { state: 'visible' });
cy.get('[data-testid="results"]').should('be.visible');
// Wait for network
await page.waitForResponse(
response => response.url().includes('/api/users') && response.status() === 200
);
// Cypress intercept
cy.intercept('GET', '/api/users').as('getUsers');
cy.wait('@getUsers');
// NEVER: Hard-coded delays
await page.waitForTimeout(5000); // Brittle and slow
// BETTER: Wait for specific condition
await page.waitForSelector('[data-testid="loaded"]');
1. Race Conditions
// Flaky
await page.goto('/dashboard');
await page.click('[data-testid="menu-button"]');
// Stable
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
await page.getByTestId('menu-button').click();
2. Test Interdependence
// Flaky: Tests share state
test('create item', async ({ page }) => {
// Creates item in shared database
});
test('list items', async ({ page }) => {
// Depends on previous test!
});
// Stable: Each test is independent
test('create item', async ({ page }) => {
await createItemViaAPI(); // Setup
await page.goto('/items');
await deleteItemViaAPI(); // Teardown
});
3. Network Instability
// Flaky: Depends on real API
await page.goto('/profile');
await expect(page.getByTestId('username')).toContainText('John');
// Stable: Mock API responses
await page.route('/api/user', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ name: 'John' }),
});
});
await page.goto('/profile');
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps ${{ matrix.browser }}
- run: npx playwright test --project=${{ matrix.browser }}
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report-${{ matrix.browser }}
path: playwright-report/
# All browsers
npx playwright test
# Specific browser
npx playwright test --project=firefox
# Test sharding (parallel machines)
npx playwright test --shard=1/4
export const testUsers = {
admin: { email: 'admin@test.com', password: 'Admin123!', role: 'admin' },
user: { email: 'user@test.com', password: 'User123!', role: 'user' },
};
import { test as base } from '@playwright/test';
export const test = base.extend({
authenticatedUser: async ({ request }, use) => {
// Setup: Create user via API
const response = await request.post('/api/auth/register', {
data: { email: `test-${Date.now()}@example.com`, password: 'Test123!' },
});
const { token, userId } = await response.json();
await use({ token, userId });
// Teardown: Delete user via API
await request.delete(`/api/users/${userId}`, {
headers: { Authorization: `Bearer ${token}` },
});
},
});
// auth.setup.ts
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="username"]', process.env.TEST_USER!);
await page.fill('[data-testid="password"]', process.env.TEST_PASSWORD!);
await page.click('[data-testid="login-button"]');
await page.context().storageState({ path: 'auth-state.json' });
});
test('handles API error gracefully', async ({ page }) => {
await page.route('/api/products', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/products');
await expect(page.getByTestId('error-message'))
.toContainText('Failed to load products');
});
import AxeBuilder from '@axe-core/playwright';
test('homepage has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
For complete examples and advanced configurations, see:
npx skills add williamzujkowski/e2e-testing-standards下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer