|$ curl https://forge-ai.dev/api/markdown?path=docs/testing/e2e
$cat docs/end-to-end-testing-(playwright).md
updated Recently·45 min read·published

End-to-End Testing (Playwright)

TestingE2EPlaywrightIntermediate to Advanced
Introduction

End-to-end (E2E) testing validates your application from the user's perspective by running automated browser interactions. Playwright is a cross-browser automation framework that supports Chromium, Firefox, and WebKit with a single API.

This guide covers Playwright setup, browser/context/page management, locators, assertions, the page objects pattern, visual regression testing, CI integration, and cross-browser testing strategies.

Setup & Installation

Playwright installs as an npm package and downloads browser binaries on first run.

terminal
Bash
1# Install Playwright
2npm init playwright@latest
3# or
4npm install -D @playwright/test
5npx playwright install
6
7# Install specific browsers (default: chromium, firefox, webkit)
8npx playwright install --with-deps chromium
9npx playwright install firefox webkit
10
11# Install system dependencies (Linux CI only)
12npx playwright install-deps
playwright.config.ts
TypeScript
1// playwright.config.ts
2import { defineConfig, devices } from "@playwright/test";
3
4export default defineConfig({
5 testDir: "./e2e",
6 fullyParallel: true,
7 forbidOnly: !!process.env.CI,
8 retries: process.env.CI ? 2 : 0,
9 workers: process.env.CI ? 1 : undefined,
10 reporter: [
11 ["html"],
12 ["json", { outputFile: "test-results/results.json" }],
13 ],
14
15 use: {
16 baseURL: "http://localhost:3000",
17 trace: "on-first-retry",
18 screenshot: "only-on-failure",
19 video: "retain-on-failure",
20 },
21
22 projects: [
23 {
24 name: "chromium",
25 use: { ...devices["Desktop Chrome"] },
26 },
27 {
28 name: "firefox",
29 use: { ...devices["Desktop Firefox"] },
30 },
31 {
32 name: "webkit",
33 use: { ...devices["Desktop Safari"] },
34 },
35 {
36 name: "mobile-chrome",
37 use: { ...devices["Pixel 5"] },
38 },
39 {
40 name: "mobile-safari",
41 use: { ...devices["iPhone 14"] },
42 },
43 ],
44
45 webServer: {
46 command: "npm run dev",
47 url: "http://localhost:3000",
48 reuseExistingServer: !process.env.CI,
49 timeout: 30000,
50 },
51});

info

The webServer config automatically starts your dev server before tests and shuts it down after. Use reuseExistingServer: true in development to avoid restarting on every test run.
Browser, Context & Page

Playwright's architecture has three layers: Browser (a browser instance), BrowserContext (an isolated session with cookies/localStorage), and Page (a single tab or window).

LayerPurposeScope
BrowserBrowser process (Chrome, Firefox, Safari)One per test run (reused)
BrowserContextIsolated session with cookies, storage, permissionsOne per test (isolated)
PageBrowser tab or windowOne or more per context
browser-context.spec.ts
TypeScript
1import { test, expect, Browser, BrowserContext } from "@playwright/test";
2
3// Playwright Test auto-creates browser, context, and page.
4// These patterns are for advanced/scaffolding usage.
5
6// Manual browser creation (e.g., for utility scripts)
7async function manualSetup() {
8 const browser: Browser = await chromium.launch({
9 headless: true,
10 args: ["--disable-gpu"],
11 });
12
13 const context: BrowserContext = await browser.newContext({
14 viewport: { width: 1280, height: 720 },
15 locale: "en-US",
16 permissions: ["geolocation"],
17 });
18
19 const page = await context.newPage();
20 await page.goto("https://example.com");
21 await browser.close();
22}
23
24// Context-level fixtures (isolated per test)
25test.use({
26 viewport: { width: 375, height: 812 }, // iPhone size
27 locale: "de-DE",
28 geolocation: { longitude: 13.405, latitude: 52.52 },
29 permissions: ["geolocation"],
30});
31
32// Multiple pages in same context (testing chat, multi-tab)
33test("multi-page interaction", async ({ context }) => {
34 const page1 = await context.newPage();
35 const page2 = await context.newPage();
36
37 await page1.goto("/chat/room1");
38 await page2.goto("/chat/room1");
39
40 await page1.fill('[data-testid="message-input"]', "Hello from page 1");
41 await page1.press('[data-testid="message-input"]', "Enter");
42
43 await expect(page2.locator("text=Hello from page 1")).toBeVisible();
44});

best practice

Never share a BrowserContext across tests. Each test gets its own context (Playwright Test handles this automatically). Context isolation ensures cookies, localStorage, and session data from one test do not leak into another.
Locators

Locators are the core of Playwright element selection. They auto-wait for elements, are resilient to DOM changes, and provide detailed error messages when elements are not found.

LocatorDescriptionExample
getByRoleElements by ARIA rolepage.getByRole('button', { name: 'Submit' })
getByTextElements by text contentpage.getByText('Welcome')
getByLabelForm controls by labelpage.getByLabel('Email')
getByPlaceholderInputs by placeholderpage.getByPlaceholder('Search...')
getByTestIdBy data-testid attributepage.getByTestId('submit-btn')
locatorCSS or XPath selectorpage.locator('.nav-item')
locators.spec.ts
TypeScript
1import { test, expect } from "@playwright/test";
2
3test.describe("Locators", () => {
4 test.beforeEach(async ({ page }) => {
5 await page.goto("/app");
6 });
7
8 test("getByRole — preferred accessibility locator", async ({ page }) => {
9 await expect(
10 page.getByRole("heading", { name: /dashboard/i })
11 ).toBeVisible();
12
13 await page.getByRole("button", { name: /submit/i }).click();
14 await page.getByRole("link", { name: /settings/i }).click();
15
16 // List items with specific name
17 await page.getByRole("listitem").filter({ hasText: "Active" });
18 });
19
20 test("getByText — text content lookup", async ({ page }) => {
21 await expect(page.getByText("Welcome back, Alice")).toBeVisible();
22
23 // Case-insensitive with regex
24 await expect(page.getByText(/welcome/i)).toBeVisible();
25
26 // Exact match
27 await page.getByText("Logout", { exact: true }).click();
28 });
29
30 test("getByLabel — form inputs", async ({ page }) => {
31 await page.getByLabel("Email").fill("user@example.com");
32 await page.getByLabel("Password").fill("secure123");
33 await page.getByRole("button", { name: /sign in/i }).click();
34 });
35
36 test("chaining and filtering locators", async ({ page }) => {
37 // Chain: find a row in a table, then a cell
38 const cell = page
39 .getByRole("row")
40 .filter({ hasText: "Alice" })
41 .getByRole("cell")
42 .last();
43
44 await expect(cell).toHaveText("Admin");
45
46 // Multiple conditions with and/or
47 const item = page
48 .getByRole("listitem")
49 .and(page.getByText("Active"));
50
51 // Get all matching elements
52 const items = page.getByRole("listitem");
53 await expect(items).toHaveCount(5);
54 });
55
56 test("locator with timeouts and retries", async ({ page }) => {
57 // Auto-waiting — Playwright retries until element is visible
58 const saveBtn = page.getByRole("button", { name: /save/i });
59
60 // Custom timeout for specific assertion
61 await expect(saveBtn).toBeVisible({ timeout: 10000 });
62
63 // Wait for element to be enabled
64 await expect(saveBtn).toBeEnabled({ timeout: 5000 });
65 });
66});
🔥

pro tip

Prefer getByRole over all other locators. It tests accessibility and mirrors how real users (including those using screen readers) navigate your app. Use getByTestId only as a last resort for elements that have no accessible identity.
Assertions

Playwright assertions auto-retry until the condition is met or the timeout expires, making tests resilient to timing issues.

assertions.spec.ts
TypeScript
1import { test, expect } from "@playwright/test";
2
3test.describe("Playwright Assertions", () => {
4 test.beforeEach(async ({ page }) => {
5 await page.goto("/dashboard");
6 });
7
8 test("toHaveText — exact or regex text match", async ({ page }) => {
9 await expect(page.locator("h1")).toHaveText("Dashboard");
10 await expect(page.locator(".status")).toHaveText(/online/i);
11 });
12
13 test("toContainText — partial text", async ({ page }) => {
14 await expect(page.locator(".notifications")).toContainText(
15 "3 unread"
16 );
17 });
18
19 test("toBeVisible / toBeHidden", async ({ page }) => {
20 await expect(
21 page.getByRole("button", { name: /submit/i })
22 ).toBeVisible();
23
24 await page.getByRole("button", { name: /cancel/i }).click();
25
26 await expect(
27 page.getByRole("button", { name: /submit/i })
28 ).toBeHidden();
29 });
30
31 test("toHaveValue — input value", async ({ page }) => {
32 const search = page.getByPlaceholder("Search...");
33 await search.fill("playwright");
34
35 await expect(search).toHaveValue("playwright");
36 });
37
38 test("toHaveAttribute", async ({ page }) => {
39 const link = page.getByRole("link", { name: /docs/i });
40
41 await expect(link).toHaveAttribute("href", "/docs");
42 await expect(link).toHaveAttribute("target", "_blank");
43 });
44
45 test("toHaveURL / toHaveTitle", async ({ page }) => {
46 await expect(page).toHaveURL("/dashboard");
47 await expect(page).toHaveTitle(/Dashboard/);
48 });
49
50 test("toHaveCount — number of matching elements", async ({ page }) => {
51 await expect(page.getByRole("listitem")).toHaveCount(5);
52 });
53
54 test("toBeChecked — checkbox/radio state", async ({ page }) => {
55 const checkbox = page.getByRole("checkbox", { name: /agree/i });
56 await checkbox.check();
57
58 await expect(checkbox).toBeChecked();
59 });
60
61 test("toBeEnabled / toBeDisabled", async ({ page }) => {
62 await expect(
63 page.getByRole("button", { name: /submit/i })
64 ).toBeDisabled();
65
66 await page.getByLabel("Email").fill("user@example.com");
67 await page.getByLabel("Password").fill("secure123");
68
69 await expect(
70 page.getByRole("button", { name: /submit/i })
71 ).toBeEnabled();
72 });
73
74 test("soft assertions — continue on failure", async ({ page }) => {
75 await expect.soft(page.locator("h1")).toHaveText("Dashboard");
76 await expect.soft(page.locator(".subtitle")).toHaveText("Overview");
77
78 // Hard assertion — stops test
79 await expect(page.getByRole("button")).toBeVisible();
80 });
81});

info

Use expect.soft for non-critical checks that should not abort the test. All failed soft assertions are reported at the end. This is useful for verifying multiple page elements without failing on the first missing one.
Page Objects Pattern

The Page Object Model (POM) encapsulates page structure and interactions into reusable classes, making tests more maintainable and readable.

page-objects.ts
TypeScript
1// pages/LoginPage.ts
2import { type Page, type Locator } from "@playwright/test";
3
4export class LoginPage {
5 readonly page: Page;
6 readonly emailInput: Locator;
7 readonly passwordInput: Locator;
8 readonly submitButton: Locator;
9 readonly errorMessage: Locator;
10
11 constructor(page: Page) {
12 this.page = page;
13 this.emailInput = page.getByLabel("Email");
14 this.passwordInput = page.getByLabel("Password");
15 this.submitButton = page.getByRole("button", { name: /sign in/i });
16 this.errorMessage = page.getByTestId("login-error");
17 }
18
19 async goto() {
20 await this.page.goto("/login");
21 }
22
23 async login(email: string, password: string) {
24 await this.emailInput.fill(email);
25 await this.passwordInput.fill(password);
26 await this.submitButton.click();
27 }
28
29 async getErrorMessage() {
30 return this.errorMessage.textContent();
31 }
32}
33
34// pages/DashboardPage.ts
35export class DashboardPage {
36 readonly page: Page;
37 readonly heading: Locator;
38 readonly userMenu: Locator;
39 readonly logoutButton: Locator;
40
41 constructor(page: Page) {
42 this.page = page;
43 this.heading = page.getByRole("heading", { name: /dashboard/i });
44 this.userMenu = page.getByTestId("user-menu");
45 this.logoutButton = page.getByRole("button", { name: /logout/i });
46 }
47
48 async logout() {
49 await this.userMenu.click();
50 await this.logoutButton.click();
51 }
52}
53
54// tests/login.spec.ts
55import { test, expect } from "@playwright/test";
56import { LoginPage } from "../pages/LoginPage";
57import { DashboardPage } from "../pages/DashboardPage";
58
59test.describe("Login flow with Page Objects", () => {
60 let loginPage: LoginPage;
61 let dashboardPage: DashboardPage;
62
63 test.beforeEach(async ({ page }) => {
64 loginPage = new LoginPage(page);
65 dashboardPage = new DashboardPage(page);
66 await loginPage.goto();
67 });
68
69 test("successful login redirects to dashboard", async () => {
70 await loginPage.login("admin@example.com", "password123");
71
72 await expect(dashboardPage.heading).toBeVisible();
73 });
74
75 test("shows error on invalid credentials", async () => {
76 await loginPage.login("wrong@example.com", "bad-password");
77
78 await expect(loginPage.errorMessage).toBeVisible();
79 await expect(loginPage.errorMessage).toHaveText(
80 /invalid credentials/i
81 );
82 });
83
84 test("logout returns to login page", async () => {
85 await loginPage.login("admin@example.com", "password123");
86 await expect(dashboardPage.heading).toBeVisible();
87
88 await dashboardPage.logout();
89
90 await expect(loginPage.emailInput).toBeVisible();
91 });
92});

best practice

Define page objects as classes with explicit Locator properties. Keep locator definitions in the constructor (eager initialization) so that failures point clearly to missing elements. Never mix test logic into page objects — they should only expose actions and queries.
Visual Regression Testing

Playwright includes built-in screenshot comparison for visual regression testing. It compares screenshots pixel-by-pixel against stored baseline images.

visual-regression.spec.ts
TypeScript
1import { test, expect } from "@playwright/test";
2
3test.describe("Visual Regression", () => {
4 test.beforeEach(async ({ page }) => {
5 await page.goto("/");
6 });
7
8 test("homepage matches snapshot", async ({ page }) => {
9 // Full page screenshot
10 await expect(page).toHaveScreenshot("homepage.png", {
11 fullPage: true,
12 maxDiffPixelRatio: 0.01,
13 });
14 });
15
16 test("specific element screenshot", async ({ page }) => {
17 const header = page.locator("header");
18
19 await expect(header).toHaveScreenshot("header.png");
20 });
21
22 test("mobile viewport screenshot", async ({ page }) => {
23 await page.setViewportSize({ width: 375, height: 812 });
24
25 await expect(page).toHaveScreenshot("homepage-mobile.png", {
26 fullPage: true,
27 });
28 });
29
30 test("dark mode screenshot", async ({ page }) => {
31 // Toggle dark mode via localStorage or UI
32 await page.evaluate(() => {
33 localStorage.setItem("theme", "dark");
34 });
35 await page.reload();
36
37 await expect(page).toHaveScreenshot("homepage-dark.png");
38 });
39
40 test("interactive state screenshot", async ({ page }) => {
41 const button = page.getByRole("button", { name: /submit/i });
42
43 // Hover state
44 await button.hover();
45 await expect(button).toHaveScreenshot("button-hover.png");
46
47 // Focus state
48 await button.focus();
49 await expect(button).toHaveScreenshot("button-focus.png");
50
51 // Active state
52 await page.mouse.down();
53 await expect(button).toHaveScreenshot("button-active.png");
54 await page.mouse.up();
55 });
56});
57
58// Update baselines:
59// npx playwright test --update-snapshots
60
61// CI: fail if baselines are missing
62// npx playwright test --update-snapshots=false

warning

Visual regression tests are fragile. Font rendering, OS-level differences, and anti-aliasing can cause false positives across environments. Set maxDiffPixelRatio (e.g., 0.01 or 1%) to tolerate minor rendering differences. Run baseline generation on your CI image, not on developer machines.
CI Integration

Running Playwright in CI requires browser binaries and system dependencies. The official Playwright Docker image simplifies setup.

ci.yml
YAML
1# .github/workflows/e2e.yml
2name: Playwright Tests
3on:
4 push:
5 branches: [main]
6 pull_request:
7 branches: [main]
8
9jobs:
10 e2e:
11 timeout-minutes: 30
12 runs-on: ubuntu-latest
13
14 services:
15 postgres:
16 image: postgres:16-alpine
17 env:
18 POSTGRES_USER: test
19 POSTGRES_PASSWORD: test
20 POSTGRES_DB: test
21 ports:
22 - 5432:5432
23 options: >-
24 --health-cmd pg_isready
25 --health-interval 10s
26 --health-timeout 5s
27 --health-retries 5
28
29 steps:
30 - uses: actions/checkout@v4
31
32 - uses: actions/setup-node@v4
33 with:
34 node-version: 20
35 cache: "npm"
36
37 - name: Install dependencies
38 run: npm ci
39
40 - name: Install Playwright browsers
41 run: npx playwright install --with-deps chromium
42
43 - name: Run Playwright tests
44 run: npx playwright test
45 env:
46 DATABASE_URL: postgres://test:test@localhost:5432/test
47
48 - name: Upload test results
49 if: always()
50 uses: actions/upload-artifact@v4
51 with:
52 name: playwright-report
53 path: playwright-report/
54 retention-days: 30
terminal
Bash
1# Run tests on specific browsers
2npx playwright test --project=chromium
3npx playwright test --project=firefox --project=webkit
4
5# Run a specific test file
6npx playwright test login.spec.ts
7
8# Run tests with a specific title pattern
9npx playwright test -g "login"
10
11# Debug mode (with Playwright Inspector)
12npx playwright test --debug
13
14# UI mode (interactive runner)
15npx playwright test --ui
16
17# Generate HTML report
18npx playwright show-report
19
20# Trace viewer
21npx playwright show-trace trace.zip

info

Use --shard=x/y to split tests across multiple CI machines: npx playwright test --shard=1/4. Combined with fullyParallel: true, this dramatically reduces CI pipeline duration for large test suites.
Cross-Browser Testing

Playwright runs on Chromium, Firefox, and WebKit with the same API. Configure projects in playwright.config.ts to run the same tests across browsers.

cross-browser.spec.ts
TypeScript
1import { test, expect, devices } from "@playwright/test";
2
3// Cross-browser test with browser-specific logic
4test.describe("Cross-browser features", () => {
5 test("touch events on mobile", async ({ page, isMobile }) => {
6 test.skip(!isMobile, "Touch test runs only on mobile");
7
8 await page.goto("/gallery");
9
10 // Swipe gesture
11 await page.touchscreen.swipe(300, 200, 100, 200);
12 await expect(page.getByTestId("slide-2")).toBeVisible();
13 });
14
15 test("webkit-specific CSS", async ({ browserName }) => {
16 test.skip(browserName !== "webkit", "WebKit scrollbar test");
17
18 await page.goto("/custom-scrollbar");
19
20 // Check custom scrollbar rendering
21 const scrollbar = page.locator("::-webkit-scrollbar");
22 await expect(scrollbar).toBeVisible();
23 });
24
25 test("browser API availability", async ({ page, browserName }) => {
26 const hasAPI = await page.evaluate(() => {
27 return typeof navigator.mediaDevices?.getUserMedia === "function";
28 });
29
30 // Adjust expectations per browser
31 if (browserName === "webkit") {
32 expect(hasAPI).toBe(true); // Requires HTTPS
33 } else {
34 expect(hasAPI).toBe(true);
35 }
36 });
37});
38
39// Device emulation matrices
40const devicesToTest = [
41 { name: "Desktop Chrome", config: devices["Desktop Chrome"] },
42 { name: "Desktop Firefox", config: devices["Desktop Firefox"] },
43 { name: "Desktop Safari", config: devices["Desktop Safari"] },
44 { name: "Pixel 5", config: devices["Pixel 5"] },
45 { name: "iPhone 14", config: devices["iPhone 14"] },
46 { name: "iPad Pro 11", config: devices["iPad Pro 11"] },
47];
48
49for (const device of devicesToTest) {
50 test.use(device.config);
51
52 test(`${device.name}: renders header`, async ({ page }) => {
53 await page.goto("/");
54 await expect(
55 page.getByRole("heading", { name: /app/i })
56 ).toBeVisible();
57 });
58}

best practice

Use test.skip with a descriptive reason for browser-specific tests. Do not skip entire test files per browser — use conditional logic within tests to handle edge cases. The HTML report clearly shows which tests ran on which browsers.
$Blueprint — Engineering Documentation·Section ID: E2E-01·Revision: 1.0