Files
claudekit/skills/playwright/references/e2e-patterns.md
T
2026-04-19 14:10:38 +07:00

9.8 KiB

E2E Testing Patterns

Deep-dive patterns for Playwright E2E tests. The main SKILL.md covers the essentials; this reference covers scaling patterns, data management, and anti-flake strategies.


Page Object Model (Scaling Pattern)

Use Page Objects when a suite grows beyond ~20 tests and multiple specs interact with the same pages. Keep them thin — locators and actions only, no assertions.

// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorAlert: Locator;

  constructor(private readonly page: Page) {
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorAlert = 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.submitButton.click();
  }
}
// e2e/specs/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

test('valid credentials redirect to dashboard', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@example.com', 'test-password');
  await expect(page).toHaveURL('/dashboard');
});

test('invalid credentials show error', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('admin@example.com', 'wrong');
  await expect(loginPage.errorAlert).toContainText('Invalid credentials');
});

When to use Page Objects vs inline locators:

  • < 20 tests: inline locators in each spec (simpler, less indirection)
  • 20-50 tests: locator helper functions or fixtures
  • 50+ tests: full Page Object Model with fixtures for injection

Test Data Management

Seed data via API calls in fixtures or beforeAll, not through the UI.

// e2e/helpers/api.ts
export async function createTestUser(request: APIRequestContext) {
  const response = await request.post('/api/v1/users', {
    data: {
      email: `test-${Date.now()}@example.com`,
      name: 'Test User',
      role: 'member',
    },
    headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
  });
  return response.json();
}

export async function deleteTestUser(request: APIRequestContext, userId: string) {
  await request.delete(`/api/v1/users/${userId}`, {
    headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
  });
}
// e2e/specs/user-management.spec.ts
import { test, expect } from '@playwright/test';
import { createTestUser, deleteTestUser } from '../helpers/api';

test.describe('User management', () => {
  let testUser: { id: string; email: string };

  test.beforeAll(async ({ request }) => {
    testUser = await createTestUser(request);
  });

  test.afterAll(async ({ request }) => {
    await deleteTestUser(request, testUser.id);
  });

  test('user appears in list', async ({ page }) => {
    await page.goto('/admin/users');
    await expect(page.getByText(testUser.email)).toBeVisible();
  });
});

Database seeding (alternative)

For complex data, seed directly via a test database. Use globalSetup to reset the DB and beforeAll per suite for specific records.

// e2e/global-setup.ts (addition)
import { execSync } from 'child_process';

async function globalSetup() {
  // Reset test database
  execSync('pnpm db:reset --force', { env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL } });
  execSync('pnpm db:seed', { env: { ...process.env, DATABASE_URL: process.env.TEST_DATABASE_URL } });

  // ... auth setup ...
}

Anti-Flake Strategies

Disable animations globally

// e2e/fixtures.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use) => {
    await page.addStyleTag({
      content: `
        *, *::before, *::after {
          animation-duration: 0s !important;
          animation-delay: 0s !important;
          transition-duration: 0s !important;
          transition-delay: 0s !important;
        }
      `,
    });
    await use(page);
  },
});

Wait for network idle after navigation

test('dashboard loads fully', async ({ page }) => {
  await page.goto('/dashboard');
  // Wait for the specific content, not generic network idle
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByRole('table')).toBeVisible();
});

Never use page.waitForLoadState('networkidle') for SPAs — it fires prematurely when the initial HTML loads but React hasn't rendered yet. Wait for the specific element you care about.

Retry flaky assertions with custom timeout

// For a known-slow operation
await expect(page.getByText('Report generated')).toBeVisible({ timeout: 30_000 });

Isolate test state with fresh contexts

test.describe('shopping cart', () => {
  test.use({ storageState: undefined }); // Fresh guest for each test

  test('add item to cart', async ({ page }) => {
    // This test starts with an empty cart every time
  });
});

Multi-Role Testing

Test different user roles in separate projects or fixtures.

// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'admin',
    use: { storageState: 'e2e/.auth/admin.json' },
    dependencies: ['setup'],
    testMatch: /.*\.admin\.spec\.ts/,
  },
  {
    name: 'member',
    use: { storageState: 'e2e/.auth/member.json' },
    dependencies: ['setup'],
    testMatch: /.*\.member\.spec\.ts/,
  },
  {
    name: 'guest',
    testMatch: /.*\.guest\.spec\.ts/,
  },
],

Or use fixtures for per-test role selection:

// e2e/fixtures.ts
type Accounts = {
  adminPage: Page;
  memberPage: Page;
};

export const test = base.extend<Accounts>({
  adminPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'e2e/.auth/admin.json' });
    await use(await ctx.newPage());
    await ctx.close();
  },
  memberPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'e2e/.auth/member.json' });
    await use(await ctx.newPage());
    await ctx.close();
  },
});

Network Interception Patterns

Wait for a specific API response before asserting

test('submitting form shows success', async ({ page }) => {
  await page.goto('/settings');

  const responsePromise = page.waitForResponse(
    (resp) => resp.url().includes('/api/v1/settings') && resp.status() === 200,
  );

  await page.getByRole('button', { name: 'Save' }).click();
  await responsePromise;

  await expect(page.getByText('Settings saved')).toBeVisible();
});

Mock a third-party service

test('shows map with mocked geocoding', async ({ page }) => {
  await page.route('**/maps.googleapis.com/**', (route) =>
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        results: [{ geometry: { location: { lat: 37.7749, lng: -122.4194 } } }],
      }),
    }),
  );

  await page.goto('/locations/new');
  await page.getByLabel('Address').fill('123 Main St');
  await page.getByRole('button', { name: 'Lookup' }).click();
  await expect(page.getByTestId('map-marker')).toBeVisible();
});

Simulate slow network

test('shows loading state on slow network', async ({ page, context }) => {
  await context.route('**/api/**', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 3000));
    await route.continue();
  });

  await page.goto('/dashboard');
  await expect(page.getByRole('progressbar')).toBeVisible();
});

Accessibility Patterns

Scan all critical pages in a single test file

// e2e/specs/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

const pages = ['/', '/login', '/dashboard', '/settings', '/users'];

for (const path of pages) {
  test(`${path} has no critical a11y violations`, async ({ page }) => {
    await page.goto(path);

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .exclude('.third-party-widget')
      .analyze();

    expect(results.violations.filter((v) => v.impact === 'critical')).toEqual([]);
  });
}

Assert specific a11y rules

test('form has proper labels', async ({ page }) => {
  await page.goto('/signup');

  const results = await new AxeBuilder({ page })
    .include('form')
    .withRules(['label', 'input-button-name'])
    .analyze();

  expect(results.violations).toEqual([]);
});

Debugging Checklist

When a test fails in CI:

  1. Download the trace artifact from GitHub Actions
  2. Open with: npx playwright show-trace trace.zip
  3. Check the timeline: click through each action to see DOM snapshots
  4. Check the console tab: look for JS errors or failed requests
  5. Check the network tab: did an API call fail or return unexpected data?
  6. If flaky: run locally with npx playwright test path/to/test --repeat-each=20
  7. If environment-specific: compare screenshots from CI vs local
  8. If timing-related: replace waitForTimeout with expect().toBeVisible() or waitForResponse()