Skip to main content

Playwright E2E Recipe

This is an opinionated, practical recipe for setting up Playwright E2E tests in an Aliz web project. It is not a tutorial โ€” it's a reference you copy from and adapt. See the Playwright tech-stack page for what Playwright is and why we use it, and the Testing Strategy for where E2E fits in the test pyramid.

Setupโ€‹

Follow the official Getting Started guide for installation and initial scaffolding.

The quickest path:

terminal
npm init playwright@latest

This generates a playwright.config.ts, an example spec, and a tests/ directory. We rename that directory to e2e/ and restructure it as described below.

tip

Commit playwright.config.ts and the e2e/ folder at the repository root โ€” not inside src/. E2E tests are a separate concern from application source code.

Project structureโ€‹

e2e/
โ”œโ”€โ”€ fixtures/ # custom fixtures (auth, db seed)
โ”œโ”€โ”€ pages/ # Page Object classes
โ”‚ โ”œโ”€โ”€ login.page.ts
โ”‚ โ””โ”€โ”€ dashboard.page.ts
โ”œโ”€โ”€ specs/ # test files (*.spec.ts)
โ”‚ โ”œโ”€โ”€ auth.spec.ts
โ”‚ โ””โ”€โ”€ dashboard.spec.ts
โ””โ”€โ”€ support/ # helpers, constants, test-data factories
playwright.config.ts
  • fixtures/ โ€” Custom test fixtures that extend Playwright's test object. Use these for shared setup like authenticated sessions or seeded database state.
  • pages/ โ€” Page Object Model classes. One file per logical page or major component.
  • specs/ โ€” The actual test files. Each file groups tests for a single feature or user flow.
  • support/ โ€” Utility functions, constants, and test-data factories shared across specs.
note

Convention: name spec files after the feature they test (auth.spec.ts, checkout.spec.ts), not the page they happen to start on.

Page Object Model (POM)โ€‹

Why POMโ€‹

  • Encapsulation โ€” selectors and interaction logic live in one place, not scattered across specs.
  • Reusability โ€” multiple specs share the same page object without duplicating locator logic.
  • Maintainability โ€” when the UI changes, you update one file instead of twenty.
  • Readability โ€” tests read like user stories: loginPage.login(email, password).

Anatomy of a Page Objectโ€‹

e2e/pages/login.page.ts
import { type Page, type Locator, expect } from '@playwright/test';

export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;

constructor(private readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
}

async goto() {
await this.page.goto('/login');
}

async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}

async expectLoggedIn() {
await expect(this.page).toHaveURL('/dashboard');
}
}

Key points:

  • The constructor takes a Page instance โ€” no global state.
  • Locators use semantic queries (getByLabel, getByRole) for resilience.
  • Action methods combine multiple steps into a single intent.
  • Assertion helpers wrap common expectations for convenience.

Using a Page Object in a testโ€‹

e2e/specs/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

test('user can log in and reach the dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);

await loginPage.goto();
await loginPage.login('user@example.com', 'correct-password');

await loginPage.expectLoggedIn();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
caution

Don't put assertions only inside page objects. The test file is the source of truth for what's being validated. Page object assertion helpers are a convenience โ€” important expectations should be visible in the spec itself.

Meaningful selectorsโ€‹

Selector priorityโ€‹

Use the most resilient selector available. Ordered from best to worst:

  1. getByRole โ€” matches accessible roles (button, heading, link). Resilient to DOM and class changes, but depends on the element's accessible name (often derived from visible text). Your default choice.
  2. getByLabel โ€” targets form controls by their associated label. Ideal for inputs.
  3. getByText โ€” matches visible text content. Good for static UI elements.
  4. getByTestId โ€” explicit data-testid hook. Use when no semantic alternative exists.
  5. CSS class or tag selector โ€” fragile, breaks on refactors. Avoid.

Adding test IDsโ€‹

When you need a test ID, add it in the component:

src/components/SubmitButton.tsx
export function SubmitButton({ label }: { label: string }) {
return (
<button type="submit" data-testid="submit-button">
{label}
</button>
);
}

Then configure the attribute name in Playwright config so getByTestId knows what to look for:

playwright.config.ts (excerpt)
import { defineConfig } from '@playwright/test';

export default defineConfig({
use: {
testIdAttribute: 'data-testid',
},
});
tip

Prefer semantic queries (getByRole, getByLabel) over data-testid. Reserve test IDs for elements that have no accessible role or distinguishing text โ€” dynamically generated containers, canvas wrappers, etc.

For the full list of locator methods, see the Playwright locators docs.

CI with GitHub Actionsโ€‹

Two workflow patterns cover most needs: fast feedback on every PR, and scheduled confidence checks against a deployed environment.

PR workflow โ€” local dev server with mocksโ€‹

This workflow runs on every push and PR. It starts the app locally (via webServer in the Playwright config) and runs tests against it.

.github/workflows/e2e-pr.yml
name: E2E Tests (PR)
on: [push, pull_request]

jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm

- run: npm ci

- name: Restore Playwright browsers cache
id: playwright-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps

- name: Save Playwright browsers cache
if: steps.playwright-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}

- name: Install system dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps

- name: Run Playwright tests
run: npx playwright test

- name: Upload trace on failure
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-traces
path: test-results/
retention-days: 7

Scheduled workflow โ€” deployed dev environmentโ€‹

This workflow runs against an actual deployed environment on weekday mornings. It catches regressions introduced by backend changes, infrastructure drift, or third-party service updates.

.github/workflows/e2e-scheduled.yml
name: E2E Tests (Scheduled)
on:
schedule:
- cron: '0 6 * * 1-5'

env:
BASE_URL: https://dev.app.example.com

jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm

- run: npm ci

- name: Restore Playwright browsers cache
id: playwright-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps

- name: Save Playwright browsers cache
if: steps.playwright-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}

- name: Install system dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps

- name: Run Playwright tests
run: npx playwright test --config=playwright.config.ts
env:
PLAYWRIGHT_BASE_URL: ${{ env.BASE_URL }}

- name: Notify on failure
if: failure()
run: echo "::error::Scheduled E2E tests failed against $BASE_URL"

Override baseURL in your config by reading the environment variable:

playwright.config.ts (excerpt)
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
},

Caching browser binariesโ€‹

Playwright downloads ~500 MB of browser binaries. Without caching, each CI run adds 1โ€“2 minutes just for downloads. Use actions/cache with a key based on the Playwright version (derived from package-lock.json):

Cache steps
- name: Restore Playwright browsers cache
id: playwright-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}

- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps

- name: Save Playwright browsers cache
if: steps.playwright-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('package-lock.json') }}
note

The cache is keyed on package-lock.json so it automatically invalidates when you upgrade Playwright (which changes browser versions).

Composite action for Playwright setupโ€‹

When multiple workflows need Playwright, extract the install-and-cache logic into a composite action. This avoids duplicating steps and lets you add resilience (e.g. a timeout for flaky CDN downloads):

.github/actions/setup-playwright/action.yml
name: Setup Playwright
description: Install Playwright Chromium with caching

inputs:
working-directory:
description: Working directory where Playwright is installed (must contain node_modules)
required: false
default: "."
package-json-path:
description: Path to Playwright package.json relative to working-directory
required: false
default: "./node_modules/playwright/package.json"
install-command:
description: Command to install Playwright on cache miss
required: false
default: "npx playwright install --with-deps chromium"

runs:
using: composite
steps:
- name: Get Playwright version
id: pw-version
shell: bash
working-directory: ${{ inputs.working-directory }}
run: echo "version=$(node -p "require('${{ inputs.package-json-path }}').version")" > $GITHUB_OUTPUT

- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ steps.pw-version.outputs.version }}

- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: timeout 600 ${{ inputs.install-command }}

- name: Install Playwright system dependencies
if: steps.playwright-cache.outputs.cache-hit == 'true'
shell: bash
working-directory: ${{ inputs.working-directory }}
run: npx playwright install-deps chromium

Then consume it from any workflow:

Usage in a workflow
      - name: Setup Playwright
uses: ./.github/actions/setup-playwright
with:
working-directory: ./packages/e2e
package-json-path: ./node_modules/@playwright/test/package.json

Benefits over inlining the steps:

  • DRY โ€” one place to update when caching strategy changes.
  • Timeout โ€” wraps the install in timeout 600 to fail fast when the Chromium CDN is unresponsive instead of hanging the workflow.
  • Version-based key โ€” derives the cache key from the actual Playwright version rather than the entire lockfile, so unrelated dependency updates don't bust the browser cache.

Sharding across machinesโ€‹

For large test suites, split execution across multiple CI machines using Playwright's built-in sharding:

Matrix strategy for sharding
jobs:
e2e:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
runs-on: ubuntu-latest
steps:
# ... setup steps ...
- name: Run Playwright tests (shard)
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}

Each shard runs a subset of tests. Combine with the merge-reports command to produce a unified HTML report. See the Playwright sharding docs for details.

Playwright config highlightsโ€‹

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

export default defineConfig({
testDir: './e2e/specs',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,

use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
testIdAttribute: 'data-testid',
},

projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// Enable in scheduled runs for full cross-browser coverage:
// { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
// { name: 'webkit', use: { ...devices['Desktop Safari'] } },
],

webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});

Key decisions:

  • fullyParallel: true โ€” tests within a file run in parallel unless explicitly serialized.
  • retries: 2 in CI โ€” absorbs transient flakiness without hiding real failures.
  • trace: 'on-first-retry' โ€” captures a trace only when a test fails and retries, keeping storage minimal.
  • webServer โ€” Playwright starts the dev server automatically. No separate terminal needed.
  • Projects โ€” use Chromium only for PR checks (speed). Add Firefox/WebKit in the scheduled workflow for coverage.

Tips & gotchasโ€‹

  • Always use webServer in config instead of manually starting the server before tests. Playwright handles startup, readiness checks, and teardown.
  • Use test.describe.configure({ mode: 'serial' }) sparingly. Most tests should be independent. Serial mode is a last resort for flows with hard ordering dependencies (e.g., create โ†’ edit โ†’ delete).
  • Use expect(page).toHaveURL() over page.url(). The assertion auto-waits for navigation to complete. A raw page.url() check is a race condition.
  • Keep E2E tests focused on critical paths. Don't duplicate what unit tests already cover. E2E tests are expensive โ€” spend them on login, checkout, onboarding, and other high-value flows.
  • Use test.slow() for legitimately slow tests rather than increasing the global timeout. This documents intent and applies a 3ร— multiplier only where needed.
  • Upload traces as artifacts on failure. Traces capture DOM snapshots, network requests, and console logs. They are invaluable for debugging headless CI runs where you can't see the browser.
  • Use --ui mode locally for interactive debugging. Run npx playwright test --ui to get a visual test runner with time-travel, step-through, and locator highlighting.

Resourcesโ€‹