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.md
source 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

  1. Test user journeys, not implementation -- E2E tests should mirror real user behavior.
  2. Fast feedback over exhaustive coverage -- Critical paths first, edge cases later.
  3. Flakiness is a bug -- Unreliable tests are worse than no tests.
  4. Isolate test data -- Each test should create and clean up its own data.
  5. 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

  1. Keep tests independent -- Each test should run in isolation.
  2. Use realistic test data -- Avoid "test" or "foo" in production-like tests.
  3. Prioritize stability over speed -- Flaky fast tests are useless.
  4. Test critical paths first -- 80/20 rule: cover 80% of usage with 20% of tests.
  5. Use Page Object Model -- Centralize selectors and actions.
  6. Avoid sleep/wait timers -- Use explicit waits for conditions.
  7. Clean up test data -- Don't pollute the database or state.
  8. Run tests in CI/CD -- Automate on every commit or PR.
  9. Monitor test flakiness -- Track and fix unreliable tests immediately.
  10. Use visual regression wisely -- Critical UI only, not everything.

Anti-Patterns to Avoid

  1. Testing every edge case in E2E -- Use unit tests for edge cases.
  2. Relying on hardcoded waits --
    sleep(5000)
    is a code smell.
  3. Sharing state between tests -- Tests must be isolated.
  4. Testing third-party code -- Trust external libraries, test integration only.
  5. Overly complex Page Objects -- Keep them focused and simple.
  6. Testing implementation details -- Test user-visible behavior.
  7. Ignoring flaky tests -- Fix or delete, never skip indefinitely.
  8. Too many E2E tests -- Balance with faster unit/integration tests.
  9. Not using test reporters -- Visibility into failures is critical.
  10. 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.