E2E Testing with Playwright: Strategies for Full-Stack Applications


E2E tests are the closest tests to real user experience — and the hardest to maintain. Playwright changed the game by offering a fast, reliable, cross-browser tool. But the tool alone isn’t enough: you need a strategy.

This guide covers patterns and practices I use in production on Symfony, Laravel, and Next.js projects.

Why Playwright became the standard

Cypress dominated the market for years, but Playwright surpassed it on several critical points:

  • Native cross-browser: Chromium, Firefox, WebKit — no plugins
  • Context isolation: each test runs in an isolated BrowserContext, no global reset needed
  • Smart auto-wait: Playwright automatically waits for elements to be actionable
  • Modern API: native async/await, no .then() chains
  • Integrated tracing: visual replay of failed tests with screenshots and videos

E2E testing strategy

The testing pyramid revisited

The classic pyramid (many unit tests, few E2E) remains valid, but proportions evolve:

         /  E2E  \       → 10-15%: critical paths
        / Integration \   → 25-30%: API, connected components
       /   Unit       \  → 55-65%: domain, utils, composables

E2E should only cover critical paths — those whose breakage costs money: registration, purchase, payment, onboarding.

For domain unit tests that form the pyramid base, see my articles on CQRS with Symfony and Clean Architecture Laravel — both show how to isolate and test business logic without a browser.

Identifying relevant E2E scenarios

Selection criteria:

  1. High business impact: checkout flow, not the “legal mentions” page
  2. Cross-layer traversal: test involves frontend + API + database
  3. Not covered by other levels: if a unit test suffices, no E2E needed
  4. Multi-step scenarios: registration → email verification → first purchase

Playwright patterns for production

Page Object Model (POM)

POM encapsulates interactions with a page in a dedicated class:

// pages/checkout.page.ts
export class CheckoutPage {
  constructor(private page: Page) {}

  async fillShippingAddress(address: Address) {
    await this.page.getByLabel('Street').fill(address.street)
    await this.page.getByLabel('City').fill(address.city)
    await this.page.getByLabel('Zip Code').fill(address.zipCode)
  }

  async selectPaymentMethod(method: 'card' | 'paypal') {
    await this.page.getByRole('radio', { name: method }).click()
  }

  async placeOrder() {
    await this.page.getByRole('button', { name: 'Confirm order' }).click()
    await this.page.waitForURL(/\/order-confirmation\//)
  }

  async getOrderNumber() {
    return this.page.getByTestId('order-number').textContent()
  }
}

The test becomes readable like a spec:

test('complete checkout flow', async ({ page }) => {
  const checkout = new CheckoutPage(page)
  await checkout.fillShippingAddress(testAddress)
  await checkout.selectPaymentMethod('card')
  await checkout.placeOrder()

  const orderNumber = await checkout.getOrderNumber()
  expect(orderNumber).toMatch(/^ORD-\d+$/)
})

Fixtures for initial state

Playwright fixtures allow preparing state before each test:

// fixtures/auth.fixture.ts
type AuthFixtures = {
  authenticatedPage: Page
}

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login')
    await page.getByLabel('Email').fill('test@example.com')
    await page.getByLabel('Password').fill('password')
    await page.getByRole('button', { name: 'Sign in' }).click()
    await page.waitForURL('/dashboard')
    await use(page)
  },
})

Visual regression testing

test('product card renders correctly', async ({ page }) => {
  await page.goto('/products/featured')
  await expect(page.getByTestId('product-card').first()).toHaveScreenshot(
    'product-card.png',
    { maxDiffPixelRatio: 0.01 }
  )
})

Reference screenshots are versioned in git. Any visual regression is automatically detected in CI.

CI/CD with GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  e2e:
    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: Run E2E tests
        run: npx playwright test

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

The if: failure() on report upload is key: we only store artifacts when tests fail, to save storage.

Framework-specific considerations

Symfony: use WebTestCase for API integration tests, reserve Playwright for E2E flows testing the frontend. The CQRS pattern facilitates fixture setup by directly executing commands.

Laravel: Laravel Feature tests cover the API. Playwright covers Inertia/Livewire frontend. Use dedicated seeders for E2E state.

Next.js: React Server Components change the game for E2E — HTML arrives pre-rendered, so tests are naturally faster and more stable.

For multi-framework projects in monorepo, Playwright configures once at root and targets different apps via webServer in config.

In summary

A robust E2E strategy with Playwright relies on:

  • Few tests, well chosen: critical paths only
  • Page Object Model: maintainability and readability
  • Fixtures: reproducible state without magic
  • CI/CD: automatic execution on every PR
  • Visual regression: visual regression detection

Playwright is the tool. Strategy is the multiplier.

KD

Kevin De Vaubree

Senior Full-Stack Developer