End-to-End Testing (Playwright)
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.
Playwright installs as an npm package and downloads browser binaries on first run.
| 1 | # Install Playwright |
| 2 | npm init playwright@latest |
| 3 | # or |
| 4 | npm install -D @playwright/test |
| 5 | npx playwright install |
| 6 | |
| 7 | # Install specific browsers (default: chromium, firefox, webkit) |
| 8 | npx playwright install --with-deps chromium |
| 9 | npx playwright install firefox webkit |
| 10 | |
| 11 | # Install system dependencies (Linux CI only) |
| 12 | npx playwright install-deps |
| 1 | // playwright.config.ts |
| 2 | import { defineConfig, devices } from "@playwright/test"; |
| 3 | |
| 4 | export 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
Playwright's architecture has three layers: Browser (a browser instance), BrowserContext (an isolated session with cookies/localStorage), and Page (a single tab or window).
| Layer | Purpose | Scope |
|---|---|---|
| Browser | Browser process (Chrome, Firefox, Safari) | One per test run (reused) |
| BrowserContext | Isolated session with cookies, storage, permissions | One per test (isolated) |
| Page | Browser tab or window | One or more per context |
| 1 | import { 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) |
| 7 | async 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) |
| 25 | test.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) |
| 33 | test("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
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.
| Locator | Description | Example |
|---|---|---|
| getByRole | Elements by ARIA role | page.getByRole('button', { name: 'Submit' }) |
| getByText | Elements by text content | page.getByText('Welcome') |
| getByLabel | Form controls by label | page.getByLabel('Email') |
| getByPlaceholder | Inputs by placeholder | page.getByPlaceholder('Search...') |
| getByTestId | By data-testid attribute | page.getByTestId('submit-btn') |
| locator | CSS or XPath selector | page.locator('.nav-item') |
| 1 | import { test, expect } from "@playwright/test"; |
| 2 | |
| 3 | test.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
Playwright assertions auto-retry until the condition is met or the timeout expires, making tests resilient to timing issues.
| 1 | import { test, expect } from "@playwright/test"; |
| 2 | |
| 3 | test.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
The Page Object Model (POM) encapsulates page structure and interactions into reusable classes, making tests more maintainable and readable.
| 1 | // pages/LoginPage.ts |
| 2 | import { type Page, type Locator } from "@playwright/test"; |
| 3 | |
| 4 | export 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 |
| 35 | export 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 |
| 55 | import { test, expect } from "@playwright/test"; |
| 56 | import { LoginPage } from "../pages/LoginPage"; |
| 57 | import { DashboardPage } from "../pages/DashboardPage"; |
| 58 | |
| 59 | test.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
Playwright includes built-in screenshot comparison for visual regression testing. It compares screenshots pixel-by-pixel against stored baseline images.
| 1 | import { test, expect } from "@playwright/test"; |
| 2 | |
| 3 | test.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
Running Playwright in CI requires browser binaries and system dependencies. The official Playwright Docker image simplifies setup.
| 1 | # .github/workflows/e2e.yml |
| 2 | name: Playwright Tests |
| 3 | on: |
| 4 | push: |
| 5 | branches: [main] |
| 6 | pull_request: |
| 7 | branches: [main] |
| 8 | |
| 9 | jobs: |
| 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 |
| 1 | # Run tests on specific browsers |
| 2 | npx playwright test --project=chromium |
| 3 | npx playwright test --project=firefox --project=webkit |
| 4 | |
| 5 | # Run a specific test file |
| 6 | npx playwright test login.spec.ts |
| 7 | |
| 8 | # Run tests with a specific title pattern |
| 9 | npx playwright test -g "login" |
| 10 | |
| 11 | # Debug mode (with Playwright Inspector) |
| 12 | npx playwright test --debug |
| 13 | |
| 14 | # UI mode (interactive runner) |
| 15 | npx playwright test --ui |
| 16 | |
| 17 | # Generate HTML report |
| 18 | npx playwright show-report |
| 19 | |
| 20 | # Trace viewer |
| 21 | npx playwright show-trace trace.zip |
info
Playwright runs on Chromium, Firefox, and WebKit with the same API. Configure projects in playwright.config.ts to run the same tests across browsers.
| 1 | import { test, expect, devices } from "@playwright/test"; |
| 2 | |
| 3 | // Cross-browser test with browser-specific logic |
| 4 | test.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 |
| 40 | const 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 | |
| 49 | for (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