Qaskills E2E Testing Patterns
Comprehensive end-to-end testing methodologies and best practices covering architecture, test design, data management, flakiness prevention, and cross-browser strategies.
install
source · Clone the upstream repo
git clone https://github.com/PramodDutta/qaskills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/PramodDutta/qaskills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/seed-skills/e2e-testing-patterns" ~/.claude/skills/pramoddutta-qaskills-e2e-testing-patterns && rm -rf "$T"
manifest:
seed-skills/e2e-testing-patterns/SKILL.mdsource content
E2E Testing Patterns Skill
You are an expert QA architect specializing in end-to-end testing patterns and methodologies. When the user asks you to design, review, or improve E2E testing strategies, follow these detailed instructions.
Core Principles
- Test user journeys, not implementation -- E2E tests should mirror real user behavior.
- Fast feedback over exhaustive coverage -- Critical paths first, edge cases later.
- Flakiness is a bug -- Unreliable tests are worse than no tests.
- Isolate test data -- Each test should create and clean up its own data.
- Test at the right level -- Not everything needs an E2E test.
Testing Pyramid and E2E Tests
/\ / \ E2E Tests (10-20%) /____\ - Critical user journeys / \ - High-value scenarios / \ - Smoke tests /__________\ Integration Tests (20-30%) / \ / \ Unit Tests (50-70%) /________________\
E2E tests should focus on:
- Happy path user journeys (login → purchase → checkout)
- Critical business flows (payment processing, data submission)
- Cross-browser compatibility on core features
- Integration between major system components
E2E tests should NOT test:
- Edge cases better covered by unit tests
- Every permutation of form validation
- Internal implementation details
- Third-party service internals
Test Architecture Patterns
1. Page Object Model (POM)
Structure:
pages/ base.page.ts # Shared base functionality login.page.ts # Login page actions and selectors dashboard.page.ts # Dashboard page actions components/ header.component.ts # Reusable header component modal.component.ts # Reusable modal component
Implementation:
// base.page.ts export abstract class BasePage { constructor(protected page: Page) {} async navigate(path: string): Promise<void> { await this.page.goto(path); } async waitForLoad(): Promise<void> { await this.page.waitForLoadState('networkidle'); } async takeScreenshot(name: string): Promise<void> { await this.page.screenshot({ path: `screenshots/${name}.png` }); } } // login.page.ts export class LoginPage extends BasePage { private readonly emailInput = this.page.getByLabel('Email'); private readonly passwordInput = this.page.getByLabel('Password'); private readonly submitButton = this.page.getByRole('button', { name: 'Sign in' }); async goto(): Promise<void> { await this.navigate('/login'); } async login(email: string, password: string): Promise<void> { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); await this.waitForLoad(); } async expectError(message: string): Promise<void> { await expect(this.page.getByRole('alert')).toContainText(message); } }
Pros:
- Clear separation of concerns
- Easy to maintain selectors in one place
- Reusable across multiple tests
Cons:
- Can become bloated if not organized well
- May encourage creating methods for every tiny action
2. Screenplay Pattern (Actor-Task Model)
Structure:
// actors/user.actor.ts export class User { constructor(private page: Page) {} async attemptsTo(...tasks: Task[]): Promise<void> { for (const task of tasks) { await task.perform(this.page); } } async shouldSee(...assertions: Assertion[]): Promise<void> { for (const assertion of assertions) { await assertion.verify(this.page); } } } // tasks/login.task.ts export class Login implements Task { constructor( private email: string, private password: string ) {} async perform(page: Page): Promise<void> { await page.getByLabel('Email').fill(this.email); await page.getByLabel('Password').fill(this.password); await page.getByRole('button', { name: 'Sign in' }).click(); } } // Usage test('user can login and view dashboard', async ({ page }) => { const user = new User(page); await user.attemptsTo( new NavigateTo('/login'), new Login('user@example.com', 'password123') ); await user.shouldSee( new PageTitle('Dashboard'), new Element('welcome-message').isVisible() ); });
Pros:
- Highly readable, business-focused tests
- Great for complex user journeys
- Easy to compose tasks
Cons:
- More upfront setup
- Can be overkill for simple apps
3. Journey-Based Testing
Organize tests by complete user journeys rather than by pages:
describe('Purchase Journey', () => { test('guest user can complete full purchase flow', async ({ page }) => { // Journey: Browse → Add to Cart → Checkout → Payment → Confirmation // Step 1: Browse products await page.goto('/products'); await page.getByRole('link', { name: 'Laptops' }).click(); // Step 2: Add to cart const product = page.getByTestId('product-123'); await product.getByRole('button', { name: 'Add to Cart' }).click(); await expect(page.getByTestId('cart-count')).toHaveText('1'); // Step 3: Checkout await page.getByRole('button', { name: 'Checkout' }).click(); await fillCheckoutForm(page, guestUserData); // Step 4: Payment await fillPaymentForm(page, testPaymentData); await page.getByRole('button', { name: 'Place Order' }).click(); // Step 5: Confirmation await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible(); const orderNumber = await page.getByTestId('order-number').textContent(); expect(orderNumber).toMatch(/^ORD-\d{6}$/); }); });
Pros:
- Tests mirror real user behavior
- Easy to understand business value
- Catches integration issues
Cons:
- Longer test execution time
- Harder to debug when failures occur mid-journey
Test Data Management Patterns
1. Test Data Factory Pattern
// factories/user.factory.ts export class UserFactory { private static counter = 0; static createUser(overrides: Partial<User> = {}): User { const id = ++this.counter; return { id: `user-${id}`, email: `testuser${id}@example.com`, name: `Test User ${id}`, role: 'user', ...overrides, }; } static createAdmin(): User { return this.createUser({ role: 'admin' }); } } // Usage in tests test('admin can delete users', async ({ page }) => { const admin = UserFactory.createAdmin(); await loginAs(page, admin); // ... rest of test });
2. Database Seeding Strategy
// fixtures/db-seed.fixture.ts export async function seedDatabase(): Promise<SeedData> { const users = await db.users.createMany([ { email: 'user1@example.com', name: 'User 1' }, { email: 'user2@example.com', name: 'User 2' }, ]); const products = await db.products.createMany([ { name: 'Product A', price: 29.99 }, { name: 'Product B', price: 49.99 }, ]); return { users, products }; } export async function cleanDatabase(): Promise<void> { await db.orders.deleteMany(); await db.products.deleteMany(); await db.users.deleteMany(); } // Use in test setup test.beforeEach(async () => { await cleanDatabase(); await seedDatabase(); });
3. API-Based Data Setup
// helpers/test-data.ts export async function createUserViaAPI(userData: CreateUserDto): Promise<User> { const response = await request.post('/api/users', { data: userData, }); return response.json(); } test('user can update profile', async ({ page }) => { // Setup: Create user via API (faster than UI) const user = await createUserViaAPI({ email: 'test@example.com', password: 'password123', }); // Test: Update profile via UI await page.goto('/profile'); await page.getByLabel('Name').fill('Updated Name'); await page.getByRole('button', { name: 'Save' }).click(); // Assertion await expect(page.getByText('Updated Name')).toBeVisible(); });
Handling Test Flakiness
1. Explicit Waits Over Implicit Waits
// ❌ BAD: Hardcoded wait await page.waitForTimeout(5000); // ✅ GOOD: Wait for specific condition await page.waitForSelector('[data-testid="results"]'); await page.waitForLoadState('networkidle'); // ✅ BETTER: Use auto-waiting assertions await expect(page.getByTestId('results')).toBeVisible();
2. Retry-able Assertions
// ✅ Automatically retries until condition is met (or timeout) await expect(page.getByRole('alert')).toHaveText('Success', { timeout: 10000 }); // ✅ Wait for element count to stabilize await expect(page.getByRole('listitem')).toHaveCount(5); // ✅ Wait for element to be in the right state await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
3. Stabilizing Network Requests
// Wait for specific API call to complete test('should load user data', async ({ page }) => { const responsePromise = page.waitForResponse( (response) => response.url().includes('/api/users') && response.status() === 200 ); await page.goto('/users'); await responsePromise; await expect(page.getByRole('heading')).toContainText('Users'); });
4. Handling Race Conditions
// ❌ BAD: Assumes element exists immediately await page.click('button'); await page.fill('input', 'text'); // ✅ GOOD: Wait for element before interaction await page.waitForSelector('button'); await page.click('button'); await page.waitForSelector('input'); await page.fill('input', 'text'); // ✅ BETTER: Use built-in auto-waiting await page.getByRole('button').click(); await page.getByRole('textbox').fill('text');
Cross-Browser Testing Strategies
1. Browser Matrix Configuration
// playwright.config.ts export default defineConfig({ 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 13'] }, }, ], });
2. Browser-Specific Test Skipping
test('should support advanced CSS features', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'Safari does not support this CSS feature yet'); await page.goto('/advanced-styles'); // ... test advanced CSS behavior });
3. Visual Regression Across Browsers
test('homepage renders consistently across browsers', async ({ page }) => { await page.goto('/'); await expect(page).toHaveScreenshot('homepage.png', { fullPage: true, maxDiffPixels: 100, // Allow minor rendering differences }); });
Test Organization Patterns
1. Feature-Based Organization
tests/ e2e/ auth/ login.spec.ts signup.spec.ts password-reset.spec.ts shopping/ browse-products.spec.ts cart-operations.spec.ts checkout.spec.ts admin/ user-management.spec.ts analytics.spec.ts
2. Smoke, Regression, and Full Suites
// Tag tests by priority test('user can login @smoke', async ({ page }) => { // Critical path }); test('user can reset password @regression', async ({ page }) => { // Less critical, run in nightly builds }); test('admin can export analytics @full', async ({ page }) => { // Run only in full test suite }); // Run subsets // npx playwright test --grep @smoke // npx playwright test --grep @regression
3. Parallel vs Serial Execution
// Run tests in parallel (default) test.describe.configure({ mode: 'parallel' }); // Run tests serially when they share state test.describe.configure({ mode: 'serial' }); test.describe('User onboarding flow', () => { test.describe.configure({ mode: 'serial' }); test('step 1: create account', async ({ page }) => { // ... }); test('step 2: verify email', async ({ page }) => { // ... }); test('step 3: complete profile', async ({ page }) => { // ... }); });
Authentication and Session Management
1. Reusable Authentication State
// auth.setup.ts import { test as setup } from '@playwright/test'; setup('authenticate', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('admin@example.com'); await page.getByLabel('Password').fill('admin123'); await page.getByRole('button', { name: 'Sign in' }).click(); await page.context().storageState({ path: 'playwright/.auth/user.json' }); }); // playwright.config.ts export default defineConfig({ projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { storageState: 'playwright/.auth/user.json', }, dependencies: ['setup'], }, ], });
2. Role-Based Authentication Fixtures
// fixtures/auth.fixture.ts export const test = base.extend<{ authenticatedPage: Page; adminPage: Page; }>({ authenticatedPage: async ({ browser }, use) => { const context = await browser.newContext({ storageState: 'playwright/.auth/user.json', }); const page = await context.newPage(); await use(page); await context.close(); }, adminPage: async ({ browser }, use) => { const context = await browser.newContext({ storageState: 'playwright/.auth/admin.json', }); const page = await context.newPage(); await use(page); await context.close(); }, }); // Usage test('admin can access admin panel', async ({ adminPage }) => { await adminPage.goto('/admin'); await expect(adminPage.getByRole('heading')).toHaveText('Admin Dashboard'); });
Performance Testing Patterns
1. Measure Page Load Metrics
test('homepage loads within 3 seconds', async ({ page }) => { const startTime = Date.now(); await page.goto('/'); await page.waitForLoadState('networkidle'); const loadTime = Date.now() - startTime; expect(loadTime).toBeLessThan(3000); });
2. Lighthouse Integration
import { playAudit } from 'playwright-lighthouse'; test('homepage meets performance standards', async ({ page }) => { await page.goto('/'); await playAudit({ page, thresholds: { performance: 90, accessibility: 95, 'best-practices': 90, seo: 90, }, }); });
Best Practices
- Keep tests independent -- Each test should run in isolation.
- Use realistic test data -- Avoid "test" or "foo" in production-like tests.
- Prioritize stability over speed -- Flaky fast tests are useless.
- Test critical paths first -- 80/20 rule: cover 80% of usage with 20% of tests.
- Use Page Object Model -- Centralize selectors and actions.
- Avoid sleep/wait timers -- Use explicit waits for conditions.
- Clean up test data -- Don't pollute the database or state.
- Run tests in CI/CD -- Automate on every commit or PR.
- Monitor test flakiness -- Track and fix unreliable tests immediately.
- Use visual regression wisely -- Critical UI only, not everything.
Anti-Patterns to Avoid
- Testing every edge case in E2E -- Use unit tests for edge cases.
- Relying on hardcoded waits --
is a code smell.sleep(5000) - Sharing state between tests -- Tests must be isolated.
- Testing third-party code -- Trust external libraries, test integration only.
- Overly complex Page Objects -- Keep them focused and simple.
- Testing implementation details -- Test user-visible behavior.
- Ignoring flaky tests -- Fix or delete, never skip indefinitely.
- Too many E2E tests -- Balance with faster unit/integration tests.
- Not using test reporters -- Visibility into failures is critical.
- Committing with .only or .skip -- Clean up before committing.
Test Reporting and Debugging
1. Rich HTML Reports
// playwright.config.ts export default defineConfig({ reporter: [ ['html', { open: 'never', outputFolder: 'test-results/html' }], ['json', { outputFile: 'test-results/results.json' }], ['junit', { outputFile: 'test-results/junit.xml' }], ], });
2. Trace Viewer for Debugging
// Enable tracing on failure export default defineConfig({ use: { trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', }, }); // View trace: // npx playwright show-trace trace.zip
3. Custom Test Annotations
test('critical payment flow', async ({ page }) => { test.info().annotations.push({ type: 'priority', description: 'critical' }); test.info().annotations.push({ type: 'ticket', description: 'JIRA-1234' }); // ... test implementation });
Continuous Improvement
- Review test failures weekly -- Identify patterns and fix root causes.
- Track test execution time -- Optimize slow tests or split them.
- Monitor flakiness rates -- Set thresholds (e.g., < 1% flaky).
- Update tests with product changes -- Keep tests in sync with features.
- Refactor Page Objects -- Keep them DRY and maintainable.
E2E testing is an investment in confidence. Done well, it catches critical bugs before production. Done poorly, it wastes time and erodes trust in automation.