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:
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.
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'stestobject. 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.
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โ
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
Pageinstance โ 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โ
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();
});
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:
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.getByLabelโ targets form controls by their associated label. Ideal for inputs.getByTextโ matches visible text content. Good for static UI elements.getByTestIdโ explicitdata-testidhook. Use when no semantic alternative exists.- CSS class or tag selector โ fragile, breaks on refactors. Avoid.
Adding test IDsโ
When you need a test ID, add it in the component:
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:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
testIdAttribute: 'data-testid',
},
});
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.
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.
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:
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):
- 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') }}
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):
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:
- 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 600to 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:
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โ
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: 2in 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
webServerin 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()overpage.url(). The assertion auto-waits for navigation to complete. A rawpage.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
--uimode locally for interactive debugging. Runnpx playwright test --uito get a visual test runner with time-travel, step-through, and locator highlighting.
Resourcesโ
- Playwright โ what it is, why we use it
- Testing Strategy โ how E2E fits in the bigger picture
- Official Playwright docs
- Playwright Best Practices
- Playwright CI guide
- Page Object Model