Skip to main content
Back to Blog
deep-diveJanuary 10, 202513 min min read

E2E Testing at Scale with Playwright: Patterns That Work

Battle-tested patterns for building maintainable E2E test suites with Playwright, from page objects to parallel execution.

Robert Fridzema

Robert Fridzema

Fullstack Developer

E2E Testing at Scale with Playwright: Patterns That Work

After writing hundreds of E2E tests across multiple projects, I've learned that the initial tests are easy - it's maintaining them at scale that's hard. This guide covers the patterns that have worked for keeping large Playwright test suites fast, reliable, and maintainable.

Project Structure

A well-organized test suite is a maintainable test suite:

tests/
├── e2e/
│   ├── auth/
│   │   ├── login.spec.ts
│   │   └── registration.spec.ts
│   ├── orders/
│   │   ├── create-order.spec.ts
│   │   └── order-history.spec.ts
│   └── settings/
│       └── profile.spec.ts
├── fixtures/
│   ├── auth.fixture.ts
│   ├── database.fixture.ts
│   └── index.ts
├── pages/
│   ├── LoginPage.ts
│   ├── DashboardPage.ts
│   └── index.ts
├── utils/
│   ├── test-data.ts
│   └── helpers.ts
└── playwright.config.ts

Group tests by feature, not by type. When requirements change, you'll update one folder instead of hunting across the codebase.

Page Object Model Done Right

Page Objects abstract UI details from test logic. Here's a pattern that scales:

// tests/pages/BasePage.ts
import { Page, Locator } from '@playwright/test'

export abstract class BasePage {
  constructor(protected page: Page) {}

  // Common elements
  get notification(): Locator {
    return this.page.locator('[data-testid="notification"]')
  }

  get loadingSpinner(): Locator {
    return this.page.locator('[data-testid="loading"]')
  }

  // Common actions
  async waitForLoad(): Promise<void> {
    await this.loadingSpinner.waitFor({ state: 'hidden' })
  }

  async expectNotification(message: string): Promise<void> {
    await expect(this.notification).toContainText(message)
  }
}
// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test'
import { BasePage } from './BasePage'

export class LoginPage extends BasePage {
  readonly url = '/login'

  // Locators - define once, use everywhere
  get emailInput(): Locator {
    return this.page.getByLabel('Email')
  }

  get passwordInput(): Locator {
    return this.page.getByLabel('Password')
  }

  get submitButton(): Locator {
    return this.page.getByRole('button', { name: 'Sign in' })
  }

  get errorMessage(): Locator {
    return this.page.locator('[data-testid="login-error"]')
  }

  // Actions - encapsulate user workflows
  async goto(): Promise<void> {
    await this.page.goto(this.url)
  }

  async login(email: string, password: string): Promise<void> {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.submitButton.click()
  }

  // Assertions - keep them in the page object
  async expectError(message: string): Promise<void> {
    await expect(this.errorMessage).toContainText(message)
  }

  async expectLoggedIn(): Promise<void> {
    await expect(this.page).toHaveURL('/dashboard')
  }
}

Now tests are clean and focused:

// tests/e2e/auth/login.spec.ts
import { test } from '../../fixtures'
import { LoginPage } from '../../pages'

test.describe('Login', () => {
  test('successful login redirects to dashboard', async ({ page }) => {
    const loginPage = new LoginPage(page)

    await loginPage.goto()
    await loginPage.login('[email protected]', 'password123')
    await loginPage.expectLoggedIn()
  })

  test('invalid credentials show error', async ({ page }) => {
    const loginPage = new LoginPage(page)

    await loginPage.goto()
    await loginPage.login('[email protected]', 'wrongpassword')
    await loginPage.expectError('Invalid credentials')
  })
})

Custom Fixtures

Fixtures set up preconditions and share state. Playwright's fixture system is powerful:

// tests/fixtures/auth.fixture.ts
import { test as base, Page } from '@playwright/test'

type AuthFixtures = {
  authenticatedPage: Page
  adminPage: Page
}

export const test = base.extend<AuthFixtures>({
  // Fixture that logs in before each test
  authenticatedPage: async ({ page }, use) => {
    // Login
    await page.goto('/login')
    await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!)
    await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
    await page.getByRole('button', { name: 'Sign in' }).click()
    await page.waitForURL('/dashboard')

    // Provide the authenticated page to the test
    await use(page)

    // Cleanup (optional)
    await page.goto('/logout')
  },

  // Admin user fixture
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext()
    const page = await context.newPage()

    await page.goto('/login')
    await page.getByLabel('Email').fill(process.env.TEST_ADMIN_EMAIL!)
    await page.getByLabel('Password').fill(process.env.TEST_ADMIN_PASSWORD!)
    await page.getByRole('button', { name: 'Sign in' }).click()
    await page.waitForURL('/admin')

    await use(page)

    await context.close()
  },
})

Usage is simple:

import { test } from '../fixtures'

test('user can view their orders', async ({ authenticatedPage }) => {
  await authenticatedPage.goto('/orders')
  await expect(authenticatedPage.locator('h1')).toContainText('Your Orders')
})

test('admin can view all users', async ({ adminPage }) => {
  await adminPage.goto('/admin/users')
  await expect(adminPage.locator('table')).toBeVisible()
})

Test Data Management

Never rely on existing data. Create what you need:

// tests/utils/test-data.ts
import { faker } from '@faker-js/faker'

export function createTestUser() {
  return {
    email: faker.internet.email(),
    password: faker.internet.password({ length: 12 }),
    name: faker.person.fullName(),
  }
}

export function createTestOrder() {
  return {
    productId: faker.string.uuid(),
    quantity: faker.number.int({ min: 1, max: 10 }),
    shippingAddress: {
      street: faker.location.streetAddress(),
      city: faker.location.city(),
      zip: faker.location.zipCode(),
    },
  }
}

Database Seeding Fixture

For tests needing specific database state:

// tests/fixtures/database.fixture.ts
import { test as base } from '@playwright/test'

type DatabaseFixtures = {
  seedUser: (data: Partial<User>) => Promise<User>
  seedOrder: (userId: string, data: Partial<Order>) => Promise<Order>
  cleanupAfterTest: void
}

export const test = base.extend<DatabaseFixtures>({
  seedUser: async ({ request }, use) => {
    const createdIds: string[] = []

    const seedUser = async (data: Partial<User>) => {
      const response = await request.post('/api/test/seed/user', { data })
      const user = await response.json()
      createdIds.push(user.id)
      return user
    }

    await use(seedUser)

    // Cleanup: delete created users
    for (const id of createdIds) {
      await request.delete(`/api/test/seed/user/${id}`)
    }
  },

  // Similar for orders...
})

Parallel Execution

Playwright runs tests in parallel by default. Make sure your tests are independent:

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

export default defineConfig({
  // Run tests in parallel
  fullyParallel: true,

  // Limit workers in CI to avoid resource issues
  workers: process.env.CI ? 4 : undefined,

  // Each test file runs in isolation
  projects: [
    {
      name: 'chromium',
      use: { browserName: 'chromium' },
    },
    {
      name: 'firefox',
      use: { browserName: 'firefox' },
    },
  ],
})

Avoiding Test Pollution

// Bad - tests depend on shared state
test('create order', async ({ page }) => {
  // Creates order with ID 123
})

test('view order', async ({ page }) => {
  await page.goto('/orders/123') // Depends on previous test!
})

// Good - each test is independent
test('create order', async ({ authenticatedPage, seedUser }) => {
  const user = await seedUser({ name: 'Test User' })
  // Test with fresh user
})

test('view order', async ({ authenticatedPage, seedUser, seedOrder }) => {
  const user = await seedUser({})
  const order = await seedOrder(user.id, {})
  await authenticatedPage.goto(`/orders/${order.id}`)
})

Handling Flaky Tests

Flaky tests destroy trust in your suite. Common causes and fixes:

1. Race Conditions

// Flaky - element might not be ready
await page.click('button')
await expect(page.locator('.result')).toBeVisible()

// Better - wait for network idle
await page.click('button')
await page.waitForLoadState('networkidle')
await expect(page.locator('.result')).toBeVisible()

// Best - wait for specific condition
await page.click('button')
await expect(page.locator('.result')).toBeVisible({ timeout: 10000 })

2. Animation Delays

// Disable animations in tests
// playwright.config.ts
export default defineConfig({
  use: {
    // Reduce motion
    reducedMotion: 'reduce',
  },
})

3. Time-Dependent Tests

// Flaky - depends on actual time
test('shows recent orders', async ({ page }) => {
  // Order from "today" might be yesterday if test runs at midnight
})

// Better - mock the clock
test('shows recent orders', async ({ page }) => {
  await page.clock.setFixedTime(new Date('2025-01-15T10:00:00'))
  // Now "today" is always Jan 15
})

4. Retry Strategy

Configure retries for CI, but not locally:

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,

  // Report flaky tests
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results.json' }],
  ],
})

API Testing Integration

Combine E2E with API tests for comprehensive coverage:

// tests/e2e/orders/api-integration.spec.ts
import { test, expect } from '@playwright/test'

test('API and UI stay in sync', async ({ page, request }) => {
  // Create order via API
  const response = await request.post('/api/orders', {
    data: { productId: '123', quantity: 2 },
    headers: { Authorization: `Bearer ${process.env.TEST_TOKEN}` },
  })
  const order = await response.json()

  // Verify it appears in UI
  await page.goto('/orders')
  await expect(page.locator(`[data-order-id="${order.id}"]`)).toBeVisible()

  // Delete via API
  await request.delete(`/api/orders/${order.id}`, {
    headers: { Authorization: `Bearer ${process.env.TEST_TOKEN}` },
  })

  // Verify it's gone from UI
  await page.reload()
  await expect(page.locator(`[data-order-id="${order.id}"]`)).not.toBeVisible()
})

CI/CD Integration

GitHub Actions Example

# .github/workflows/e2e.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Start application
        run: npm run dev &
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

      - name: Wait for app
        run: npx wait-on http://localhost:3000

      - name: Run tests
        run: npx playwright test

      - name: Upload report
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Performance Monitoring

Track test performance over time:

// tests/utils/metrics.ts
import { test as base } from '@playwright/test'

export const test = base.extend({
  autoTestMetrics: [async ({ page }, use, testInfo) => {
    const startTime = Date.now()

    await use()

    const duration = Date.now() - startTime

    // Log slow tests
    if (duration > 30000) {
      console.warn(`Slow test: ${testInfo.title} took ${duration}ms`)
    }

    // Could send to monitoring service
    // await metrics.record('test_duration', duration, { test: testInfo.title })
  }, { auto: true }],
})

Key Takeaways

  1. Page Objects reduce maintenance - One change fixes all tests
  2. Fixtures share setup logic - DRY for test preconditions
  3. Generate test data - Never depend on existing state
  4. Isolate tests completely - Parallel execution reveals hidden dependencies
  5. Wait for conditions, not time - waitForSelector beats sleep
  6. Flaky tests need fixing - A flaky test is worse than no test

The goal isn't 100% E2E coverage - it's confidence that critical paths work. Focus on user journeys that matter most.


Building a test suite? Get in touch - I've set up testing infrastructure for multiple teams and happy to share more strategies.

#Playwright #Testing #TypeScript #CI/CD #Quality
Share: