QA

Playwright E2E testing: a practical guide for production test suites.


Playwright is the E2E testing framework we use across all 47Network Studio QA engagements. It's replaced Cypress for most of our work โ€” cross-browser support that actually works, a much better async model, network interception that's genuinely useful, and a trace viewer that makes debugging test failures in CI tolerable. This post covers the architecture decisions that keep Playwright suites maintainable at scale: the page object model, authentication fixtures that don't slow down every test, parallel execution configuration, and CI integration that doesn't flake.

Project structure

tests/
  fixtures/          # Shared test setup and teardown
    auth.fixture.ts  # Authenticated page contexts
    db.fixture.ts    # Database seeding and cleanup
  pages/             # Page Object Model classes
    login.page.ts
    checkout.page.ts
    dashboard.page.ts
  specs/             # Test files
    auth/
      login.spec.ts
      registration.spec.ts
    checkout/
      happy-path.spec.ts
      payment-failure.spec.ts
  helpers/           # Utilities, API clients, test data factories
    api.ts
    factories.ts
playwright.config.ts

The page object model: done right

A page object wraps the selectors and actions for a page so tests read like user stories, not CSS selector soup. The key principle: page objects return other page objects โ€” actions that navigate return the page you arrive on.

// pages/checkout.page.ts
import { type Page, type Locator, expect } from '@playwright/test';
import { OrderConfirmationPage } from './order-confirmation.page';

export class CheckoutPage {
  readonly page: Page;

  // Locators โ€” use role/label/text selectors over CSS IDs where possible
  // These survive UI refactors better than CSS selectors
  readonly emailInput:   Locator;
  readonly cardNumber:   Locator;
  readonly placeOrder:   Locator;
  readonly errorBanner:  Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput  = page.getByRole('textbox', { name: 'Email address' });
    this.cardNumber  = page.getByLabel('Card number');
    this.placeOrder  = page.getByRole('button', { name: 'Place order' });
    this.errorBanner = page.getByRole('alert');
  }

  async fillEmail(email: string) {
    await this.emailInput.fill(email);
  }

  async fillCard(number: string, expiry: string, cvc: string) {
    await this.cardNumber.fill(number);
    await this.page.getByLabel('Expiry').fill(expiry);
    await this.page.getByLabel('CVC').fill(cvc);
  }

  async submitOrder(): Promise {
    await this.placeOrder.click();
    return new OrderConfirmationPage(this.page);
  }

  async expectError(message: string) {
    await expect(this.errorBanner).toContainText(message);
  }
}

Authentication fixtures: one login per test file, not per test

The most common Playwright performance problem is logging in before every test. With 80 tests that each take 2โ€“3 seconds to authenticate, you've added 3โ€“4 minutes to your suite run for no reason. Use storageState to authenticate once per role and reuse the session:

// fixtures/auth.fixture.ts
import { test as base, type Page } from '@playwright/test';
import * as path from 'path';

// Auth state files โ€” generated once per suite run
const STORAGE = {
  admin:    path.join(__dirname, '../.auth/admin.json'),
  customer: path.join(__dirname, '../.auth/customer.json'),
};

// Global setup โ€” runs once before all tests
// playwright.config.ts: globalSetup: './fixtures/global-setup.ts'
export async function globalSetup() {
  const { chromium } = await import('@playwright/test');
  const browser = await chromium.launch();

  for (const [role, file] of Object.entries(STORAGE)) {
    const page = await browser.newPage();
    await page.goto('https://staging.example.com/login');
    await page.getByLabel('Email').fill(`${role}@test.example.com`);
    await page.getByLabel('Password').fill(process.env[`TEST_${role.toUpperCase()}_PASSWORD`]!);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('**/dashboard');
    await page.context().storageState({ path: file });
    await page.close();
  }

  await browser.close();
}

// Fixture that injects an authenticated page
export const test = base.extend<{
  adminPage: Page;
  customerPage: Page;
}>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: STORAGE.admin });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
  customerPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: STORAGE.customer });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

Network interception: testing without external dependencies

Payment processors, email providers, and third-party APIs shouldn't be called in E2E tests. Mock them at the network level:

// specs/checkout/payment-failure.spec.ts
import { test, expect } from '../fixtures/auth.fixture';

test('shows error when card is declined', async ({ customerPage }) => {
  // Intercept the payment API call before it happens
  await customerPage.route('**/api/payments', async (route) => {
    await route.fulfill({
      status: 402,
      contentType: 'application/json',
      body: JSON.stringify({
        error: 'card_declined',
        message: 'Your card was declined.',
      }),
    });
  });

  await customerPage.goto('/checkout');
  // ... fill checkout form ...
  const confirmPage = await checkoutPage.submitOrder();
  // Route intercept catches the payment call before it hits Stripe
  await checkoutPage.expectError('Your card was declined.');
});

Parallel execution and sharding

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/specs',
  globalSetup: './tests/fixtures/global-setup.ts',
  
  // Run test files in parallel โ€” workers = CPU cores by default
  fullyParallel: true,
  workers: process.env.CI ? 4 : undefined,   // Limit to 4 in CI to avoid rate limits

  // Retry flaky tests once in CI, never locally
  retries: process.env.CI ? 1 : 0,
  
  // Fail fast in CI if too many tests fail
  maxFailures: process.env.CI ? 10 : undefined,

  reporter: [
    ['html'],                        // Local: open with 'npx playwright show-report'
    ['junit', { outputFile: 'test-results/junit.xml' }],  // For CI artifact upload
  ],

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',         // Capture trace on first retry for debugging
    video: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    // Skip Firefox in CI to save time โ€” run it nightly
    ...(process.env.CI ? [] : [{ name: 'firefox', use: { ...devices['Desktop Firefox'] } }]),
  ],
});

Sharding for large suites: when your suite grows past 200 tests, use Playwright's built-in sharding to split across multiple CI workers: npx playwright test --shard=1/4 runs the first quarter of tests. In GitHub Actions, run 4 parallel jobs with strategy: matrix: shard: [1, 2, 3, 4]. Merge the HTML reports with npx playwright merge-reports in a final job.

In 47Network Studio QA engagements a typical Playwright suite covers: authenticated user flows (login, registration, password reset), core business flows (checkout, form submissions, data exports), API contract tests via page.request, and accessibility checks via @axe-core/playwright. The SaaS platform engagement delivered 87 test cases in this structure, running in under 8 minutes on 4 parallel workers.


โ† Back to Blog QA Maturity Model โ†’