Stackvora.tech

Playwright End-to-End Testing

Playwright has quietly become the default for browser E2E testing in 2026 — better parallelism than Cypress, first-class TypeScript, and a genuinely great…

Playwright has quietly become the default for browser E2E testing in 2026 — better parallelism than Cypress, first-class TypeScript, and a genuinely great debug story. Its robust API and intelligent auto-waiting mechanisms simplify complex interaction scenarios, making it an excellent choice for ensuring application quality across various browsers and environments.

Installation and Initial Setup

Getting started with Playwright is straightforward, typically involving a single command that scaffolds a project with sensible defaults.

npm init playwright@latest --yes

This command performs several critical actions:

  • Installs the @playwright/test package.
  • Sets up a tests/ directory, often pre-populated with an example test file (e.g., example.spec.ts).
  • Creates a playwright.config.ts file, which is the central configuration for your tests.
  • Installs browser binaries for Chromium, Firefox, and WebKit by default.
  • Optionally adds a GitHub Actions workflow for CI integration.

The playwright.config.ts file is crucial. A basic configuration might look like this:

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

// Load environment variables from .env file
dotenv.config({ path: path.resolve(__dirname, '.env') });

export default defineConfig({
  testDir: './tests',
  fullyParallel: true, // Run tests in parallel across all workers
  forbidOnly: !!process.env.CI, // Disallow .only in CI
  retries: process.env.CI ? 2 : 0, // Retry failed tests in CI
  workers: process.env.CI ? 4 : undefined, // Number of parallel workers
  reporter: [
    ['list'], // Console reporter
    ['html', { open: 'never' }], // HTML report
    ['github'], // GitHub Actions annotations
  ],
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
    trace: 'retain-on-failure', // Record traces for failed tests
    screenshot: 'only-on-failure', // Capture screenshots on failure
    video: 'retain-on-failure', // Record video on failure
    headless: true, // Run browsers in headless mode by default
    viewport: { width: 1280, height: 720 }, // Default viewport size
    actionTimeout: 10 * 1000, // Timeout for actions like click, fill
    navigationTimeout: 30 * 1000, // Timeout for page navigations
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    // Example for mobile devices
    // {
    //   name: 'Mobile Chrome',
    //   use: { ...devices['Pixel 5'] },
    // },
    // {
    //   name: 'Mobile Safari',
    //   use: { ...devices['iPhone 12'] },
    // },
  ],
  webServer: {
    command: 'npm run start', // Command to start your dev server
    url: 'http://localhost:3000',
    timeout: 120 * 1000,
    reuseExistingServer: !process.env.CI, // Reuse server locally, start new in CI
  },
});

Crafting Robust Tests with Playwright Test

Playwright's testing philosophy emphasizes user-centric interactions and reliable assertions. The @playwright/test framework provides powerful primitives for this.

A Robust First Test Example

Consider a typical login flow. Playwright makes interacting with elements and asserting outcomes intuitive.

// tests/login.spec.ts
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
  test("successful login redirects to dashboard", async ({ page }) => {
    // Navigate to the base URL (configured in playwright.config.ts)
    await page.goto("/");
    await expect(page).toHaveTitle(/Your App Title/); // Initial title check

    // Click the sign-in link using its accessible name
    await page.getByRole("link", { name: "Sign in" }).click();

    // Ensure we're on the login page
    await expect(page).toHaveURL(/\/login/);

    // Fill in credentials using accessible labels
    await page.getByLabel("Email").fill("dev@example.com");
    await page.getByLabel("Password").fill(process.env.TEST_PASSWORD || "password123");

    // Click the sign-in button using a regex for case-insensitive matching
    await page.getByRole("button", { name: /sign in/i }).click();

    // Assert redirection to the dashboard and visibility of key elements
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
    await expect(page.getByText("Welcome, dev@example.com")).toBeVisible();
  });

  test("invalid credentials show error message", async ({ page }) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill("invalid@example.com");
    await page.getByLabel("Password").fill("wrongpassword");
    await page.getByRole("button", { name: /sign in/i }).click();

    await expect(page.getByText("Invalid email or password.")).toBeVisible();
    await expect(page).toHaveURL(/\/login/); // Should remain on login page
  });
});

Best Practices for Element Selection and Assertions

  • Prefer getByRole and getByLabel: These locators mimic how a user perceives and interacts with elements, making tests more resilient to UI changes and improving accessibility. Examples: page.getByRole("button", { name: "Submit" }), page.getByLabel("Username").
  • Use getByText for static text: Useful for asserting content within paragraphs, spans, or error messages. Example: page.getByText("Your order has been placed.").
  • Avoid brittle CSS selectors: While page.locator('.some-class #some-id') works, it ties tests tightly to internal implementation details. Use them sparingly, perhaps for elements without clear roles or labels, or for performance-critical scenarios (though Playwright's auto-retries often mitigate this).
  • Leverage auto-waiting: Playwright automatically waits for elements to be visible, enabled, and stable before performing actions or assertions. You almost never need explicit waitForTimeout() or waitForSelector() calls.
  • Assert visibility: Use expect(...).toBeVisible() for most assertions. It's often more meaningful than just checking for existence in the DOM.
  • One test = one user journey: Each test() block should cover a single, well-defined user interaction flow. Avoid chaining unrelated assertions or multiple distinct user stories into one test.
  • Reset state per test: Ensure tests are independent. This often means clearing database state, logging out users, or using fresh accounts between tests or within test.beforeEach() blocks. Playwright's test.use() for fixture-based state management can be very powerful here.

Configuring for Speed and Reliability in CI

Optimizing Playwright for continuous integration (CI) environments is key to fast feedback loops.

Key Configuration Options in playwright.config.ts

Option Description Typical CI Value
fullyParallel Run all test files in parallel. true
workers Number of parallel worker processes. process.env.CI ? 4 : undefined (adjust based on CPU cores)
retries Number of times to retry failed tests. Mitigates flakiness. process.env.CI ? 2 : 0
use.trace Records execution trace for debugging. 'retain-on-failure'
use.screenshot Captures screenshots. 'only-on-failure' (reduces artifact size)
use.video Records video of test runs. 'retain-on-failure' (can be resource-intensive, use carefully)
reporter Defines test report formats. [['list'], ['html', { open: 'never' }], ['github']]
webServer Starts a local web server before tests. Essential for testing frontend apps. command: 'npm run start', url: 'http://localhost:3000', reuseExistingServer: false

The github reporter is particularly useful for GitHub Actions, as it provides inline annotations for failing tests directly in your pull requests.

Effective Debugging Strategies

Playwright offers an unparalleled debugging experience, crucial for quickly pinpointing the cause of test failures.

Interactive Debugging with Playwright Inspector

When a test fails or you need to understand an interaction, the Playwright Inspector is your primary tool.

npx playwright test --debug tests/specific-test.spec.ts

This command:

  • Launches the browser in non-headless mode.
  • Opens the Playwright Inspector, which highlights elements as you hover over them, shows the action log, and allows stepping through the test.
  • Allows you to generate locators by clicking on elements.

You can also insert await page.pause(); directly into your test code to pause execution at a specific point and interact with the page via the browser's DevTools or Playwright Inspector.

Post-Mortem Analysis with Traces

For tests that fail in CI or when running without --debug, Playwright can generate a trace file (trace.zip) containing a detailed recording of the test run, including:

  • Step-by-step execution logs.
  • Screenshots at each action.
  • DOM snapshots.
  • Network requests.
  • Console logs.

To view a trace:

npx playwright show-trace path/to/trace.zip

This opens a web-based UI that allows you to replay the test, inspect the state of the page at any step, and analyze network activity. Ensure use.trace: 'retain-on-failure' is set in your playwright.config.ts for traces to be automatically saved.

Common Pitfalls

  • Forgetting await: Playwright's API is heavily asynchronous. Missing an await before an action (e.g., page.click()) can lead to unpredictable test behavior or flaky failures.
  • Over-reliance on waitForTimeout(): Explicit waits are an anti-pattern. Playwright's auto-waiting handles most synchronization issues. If you find yourself using waitForTimeout(), it usually indicates an issue with locator strategy or an animation that needs to be awaited differently (e.g., waiting for an element to disappear).
  • Hardcoding URLs: Always use baseURL in playwright.config.ts and then relative paths in page.goto('/') or page.goto('/dashboard'). This makes tests portable across environments (development, staging, production).
  • Ignoring accessibility locators: While CSS selectors work, ignoring getByRole and getByLabel misses an opportunity to make tests more robust and aligned with user experience.
  • State leakage between tests: If tests are not properly isolated (e.g., a login in one test affects another), you'll encounter flaky tests that pass locally but fail in CI. Implement test.beforeEach() or global setup/teardown to manage state.
  • Not handling network requests: For applications heavily relying on APIs, sometimes waiting for specific network responses using page.waitForResponse() or mocking with page.route() is necessary to ensure the UI is in the expected state before proceeding.

Back to the knowledge base · Ask the AI assistant