532 lines
14 KiB
Markdown
532 lines
14 KiB
Markdown
# E2E Testing Patterns Implementation Playbook
|
|
|
|
This file contains detailed patterns, checklists, and code samples referenced by the skill.
|
|
|
|
## Core Concepts
|
|
|
|
### 1. E2E Testing Fundamentals
|
|
|
|
**What to Test with E2E:**
|
|
- Critical user journeys (login, checkout, signup)
|
|
- Complex interactions (drag-and-drop, multi-step forms)
|
|
- Cross-browser compatibility
|
|
- Real API integration
|
|
- Authentication flows
|
|
|
|
**What NOT to Test with E2E:**
|
|
- Unit-level logic (use unit tests)
|
|
- API contracts (use integration tests)
|
|
- Edge cases (too slow)
|
|
- Internal implementation details
|
|
|
|
### 2. Test Philosophy
|
|
|
|
**The Testing Pyramid:**
|
|
```
|
|
/\
|
|
/E2E\ ← Few, focused on critical paths
|
|
/─────\
|
|
/Integr\ ← More, test component interactions
|
|
/────────\
|
|
/Unit Tests\ ← Many, fast, isolated
|
|
/────────────\
|
|
```
|
|
|
|
**Best Practices:**
|
|
- Test user behavior, not implementation
|
|
- Keep tests independent
|
|
- Make tests deterministic
|
|
- Optimize for speed
|
|
- Use data-testid, not CSS selectors
|
|
|
|
## Playwright Patterns
|
|
|
|
### Setup and Configuration
|
|
|
|
```typescript
|
|
// 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'] } },
|
|
],
|
|
});
|
|
```
|
|
|
|
### Pattern 1: Page Object Model
|
|
|
|
```typescript
|
|
// 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('user@example.com', '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('invalid@example.com', 'wrong');
|
|
|
|
const error = await loginPage.getErrorMessage();
|
|
expect(error).toContain('Invalid credentials');
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Fixtures for Test Data
|
|
|
|
```typescript
|
|
// 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: 'admin@example.com',
|
|
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();
|
|
});
|
|
```
|
|
|
|
### Pattern 3: Waiting Strategies
|
|
|
|
```typescript
|
|
// ❌ 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(),
|
|
]);
|
|
```
|
|
|
|
### Pattern 4: Network Mocking and Interception
|
|
|
|
```typescript
|
|
// 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 Patterns
|
|
|
|
### Setup and Configuration
|
|
|
|
```typescript
|
|
// 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
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### Pattern 1: Custom Commands
|
|
|
|
```typescript
|
|
// 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('user@example.com', 'password');
|
|
cy.dataCy('submit-button').click();
|
|
```
|
|
|
|
### Pattern 2: Cypress Intercept
|
|
|
|
```typescript
|
|
// 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();
|
|
});
|
|
});
|
|
```
|
|
|
|
## Advanced Patterns
|
|
|
|
### Pattern 1: Visual Regression Testing
|
|
|
|
```typescript
|
|
// 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');
|
|
});
|
|
```
|
|
|
|
### Pattern 2: Parallel Testing with Sharding
|
|
|
|
```typescript
|
|
// 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
|
|
```
|
|
|
|
### Pattern 3: Accessibility Testing
|
|
|
|
```typescript
|
|
// 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([]);
|
|
});
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Use Data Attributes**: `data-testid` or `data-cy` for stable selectors
|
|
2. **Avoid Brittle Selectors**: Don't rely on CSS classes or DOM structure
|
|
3. **Test User Behavior**: Click, type, see - not implementation details
|
|
4. **Keep Tests Independent**: Each test should run in isolation
|
|
5. **Clean Up Test Data**: Create and destroy test data in each test
|
|
6. **Use Page Objects**: Encapsulate page logic
|
|
7. **Meaningful Assertions**: Check actual user-visible behavior
|
|
8. **Optimize for Speed**: Mock when possible, parallel execution
|
|
|
|
```typescript
|
|
// ❌ 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('user@example.com');
|
|
cy.get('[data-testid="email-input"]').type('user@example.com');
|
|
```
|
|
|
|
## Common Pitfalls
|
|
|
|
- **Flaky Tests**: Use proper waits, not fixed timeouts
|
|
- **Slow Tests**: Mock external APIs, use parallel execution
|
|
- **Over-Testing**: Don't test every edge case with E2E
|
|
- **Coupled Tests**: Tests should not depend on each other
|
|
- **Poor Selectors**: Avoid CSS classes and nth-child
|
|
- **No Cleanup**: Clean up test data after each test
|
|
- **Testing Implementation**: Test user behavior, not internals
|
|
|
|
## Debugging Failing Tests
|
|
|
|
```typescript
|
|
// 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
|
|
```
|
|
|
|
## Resources
|
|
|
|
- **references/playwright-best-practices.md**: Playwright-specific patterns
|
|
- **references/cypress-best-practices.md**: Cypress-specific patterns
|
|
- **references/flaky-test-debugging.md**: Debugging unreliable tests
|
|
- **assets/e2e-testing-checklist.md**: What to test with E2E
|
|
- **assets/selector-strategies.md**: Finding reliable selectors
|
|
- **scripts/test-analyzer.ts**: Analyze test flakiness and duration
|