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:
- High business impact: checkout flow, not the “legal mentions” page
- Cross-layer traversal: test involves frontend + API + database
- Not covered by other levels: if a unit test suffices, no E2E needed
- 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.
Kevin De Vaubree
Senior Full-Stack Developer