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
Fullstack Developer

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
- Page Objects reduce maintenance - One change fixes all tests
- Fixtures share setup logic - DRY for test preconditions
- Generate test data - Never depend on existing state
- Isolate tests completely - Parallel execution reveals hidden dependencies
- Wait for conditions, not time -
waitForSelectorbeatssleep - 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.
Related Articles

Building Production Electron Apps with Vue 3 and Azure SSO
A comprehensive guide to building secure, enterprise-ready Electron applications with Vue 3, TypeScript, and Azure AD authentication.

Migrating Legacy Databases: A Real-World n8n + PostgreSQL Story
How we migrated decades of business data from a legacy system to a modern Laravel application using n8n workflows and PostgreSQL.

Implementing RAG with Laravel and pgvector
A practical guide to building Retrieval-Augmented Generation systems in Laravel using PostgreSQL's pgvector extension for semantic search.