devops2025-01-15Β·12Β·226/348

Playwright Browser Automation: Advanced Patterns and Debugging

Setting up Playwright for reliable browser automation including test fixtures, API mocking, parallel execution, and CI/CD integration patterns.

Introduction

Playwright has become the preferred browser automation tool for end-to-end testing, replacing Puppeteer and Selenium in many projects. Its auto-waiting mechanism, cross-browser support, and built-in test runner make it significantly more reliable than older tools. However, configuring Playwright for CI/CD environments and handling complex user flows requires understanding its internal mechanics.

This post covers the setup patterns that produce reliable test suites, the debugging techniques that reduce test maintenance, and the CI/CD configurations that minimize execution time.

Environment

node --version
# v20.11.0

npm list playwright @playwright/test
# playwright@1.41.0
# @playwright/test@1.41.0

# Install browsers
npx playwright install --with-deps
# Installing: chromium, firefox, webkit

The project structure:

e2e-tests/
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ auth.setup.ts
β”‚   β”œβ”€β”€ login.spec.ts
β”‚   β”œβ”€β”€ dashboard.spec.ts
β”‚   └── api.spec.ts
β”œβ”€β”€ fixtures/
β”‚   β”œβ”€β”€ base.ts
β”‚   └── auth.ts
β”œβ”€β”€ utils/
β”‚   β”œβ”€β”€ api-mock.ts
β”‚   └── test-data.ts
β”œβ”€β”€ playwright.config.ts
└── package.json

Problem

The first issue was flaky tests that passed locally but failed in CI. The tests relied on timing assumptions that worked on a fast development machine but timed out on slower CI runners:

// This test is flaky because of timing assumptions
test("dashboard loads data", async ({ page }) => {
  await page.goto("/dashboard");
  await page.waitForTimeout(2000); // Fixed wait - unreliable
  await expect(page.locator(".data-table")).toBeVisible();
});

The second issue was test isolation. Tests that ran in parallel shared browser state, causing authentication failures and data contamination between tests:

// Tests share cookies and localStorage
test("user A sees their data", async ({ page }) => {
  await login(page, "userA@example.com");
  await page.goto("/dashboard");
  await expect(page.locator(".user-name")).toHaveText("User A");
});

test("user B sees their data", async ({ page }) => {
  // This test might see User A's data if running in parallel
  await login(page, "userB@example.com");
  await page.goto("/dashboard");
  await expect(page.locator(".user-name")).toHaveText("User B");
});

The third issue was slow test execution. The full test suite took 15 minutes to complete, which was too long for rapid iteration. Every test navigated to the home page and performed login, even when testing API endpoints that did not require authentication.

Analysis

Playwright's auto-waiting mechanism retries assertions until they succeed or timeout. However, the default timeout of 30 seconds is too generous for fast feedback and too short for complex operations. The waitForTimeout method is explicitly discouraged because it introduces fixed delays that are either too long (wasting time) or too short (causing flakiness).

Test isolation in Playwright requires separate browser contexts. Each context has its own cookies, localStorage, and session state. When tests share a context, they share state, which causes the authentication contamination issue.

The slow execution problem stems from sequential test execution and redundant navigation. Playwright supports parallel execution within files and across files, but the configuration must be tuned for the CI environment's available resources.

Solution

Configure Playwright with proper timeouts and parallelism:

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

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ["html", { open: "never" }],
    ["junit", { outputFile: "test-results/junit.xml" }],
  ],
  use: {
    baseURL: process.env.BASE_URL || "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  timeout: 30000,
  expect: {
    timeout: 5000,
  },
  projects: [
    {
      name: "setup",
      testMatch: /.*\.setup\.ts/,
    },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "tests/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
        storageState: "tests/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "api",
      testMatch: /.*\.api\.ts/,
      use: {
        baseURL: process.env.API_URL || "http://localhost:4000",
      },
    },
  ],
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
});

Create auth fixtures for test isolation:

// fixtures/auth.ts
import { test as base, expect } from "@playwright/test";

type AuthFixtures = {
  authenticatedPage: any;
  adminPage: any;
};

export const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    // Login and save storage state
    await page.goto("/login");
    await page.fill('[data-testid="email"]', "user@example.com");
    await page.fill('[data-testid="password"]', "password123");
    await page.click('[data-testid="submit"]');
    await page.waitForURL("/dashboard");

    // Provide the authenticated page
    await use(page);
  },

  adminPage: async ({ page }, use) => {
    await page.goto("/login");
    await page.fill('[data-testid="email"]', "admin@example.com");
    await page.fill('[data-testid="password"]', "admin123");
    await page.click('[data-testid="submit"]');
    await page.waitForURL("/admin");

    await use(page);
  },
});

export { expect };

Write reliable tests with auto-waiting:

// tests/dashboard.spec.ts
import { test, expect } from "../fixtures/auth";

test.describe("Dashboard", () => {
  test("displays user metrics after login", async ({ authenticatedPage }) => {
    const page = authenticatedPage;

    // Navigate to dashboard
    await page.goto("/dashboard");

    // Auto-wait for the metrics to load
    // This retries until the element is visible or timeout
    await expect(page.locator('[data-testid="metrics-card"]')).toHaveCount(4);

    // Verify specific metric values
    await expect(
      page.locator('[data-testid="total-users"]')
    ).toContainText("1,234");

    // Wait for the chart to render
    await expect(page.locator("svg.chart")).toBeVisible();

    // Verify the chart has data points
    const dataPoints = page.locator("svg.chart circle");
    await expect(dataPoints).toHaveCount(10);
  });

  test("filters data by date range", async ({ authenticatedPage }) => {
    const page = authenticatedPage;
    await page.goto("/dashboard");

    // Open date picker
    await page.click('[data-testid="date-range-picker"]');

    // Select last 7 days
    await page.click('[data-testid="preset-last-7-days"]');

    // Verify the table updates with filtered data
    await expect(page.locator(".data-table tbody tr")).toHaveCount(7);
  });
});

Mock API responses for faster, more reliable tests:

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

test.describe("API Endpoints", () => {
  test("GET /api/users returns user list", async ({ request }) => {
    const response = await request.get("/api/users", {
      headers: {
        Authorization: `Bearer ${process.env.TEST_TOKEN}`,
      },
    });

    expect(response.ok()).toBeTruthy();
    const data = await response.json();
    expect(data.users).toBeInstanceOf(Array);
    expect(data.users.length).toBeGreaterThan(0);
  });

  test("POST /api/users creates a new user", async ({ request }) => {
    const response = await request.post("/api/users", {
      data: {
        name: "Test User",
        email: "test@example.com",
        role: "user",
      },
    });

    expect(response.status()).toBe(201);
    const data = await response.json();
    expect(data.user.name).toBe("Test User");
  });

  test("handles API errors gracefully", async ({ page }) => {
    // Mock the API to return an error
    await page.route("**/api/users", (route) => {
      route.fulfill({
        status: 500,
        contentType: "application/json",
        body: JSON.stringify({ error: "Internal Server Error" }),
      });
    });

    await page.goto("/users");
    await expect(page.locator('[data-testid="error-message"]')).toContainText(
      "Failed to load users"
    );
  });
});

Create reusable test utilities:

// utils/test-data.ts
import { Page, expect } from "@playwright/test";

export async function waitForTableLoad(page: Page, expectedRows: number) {
  const rows = page.locator("table tbody tr");
  await expect(rows).toHaveCount(expectedRows, { timeout: 10000 });
}

export async function fillForm(page: Page, data: Record) {
  for (const [label, value] of Object.entries(data)) {
    const field = page.locator(`[data-testid="${label}"]`);
    await field.fill(value);
  }
}

export async function selectFromDropdown(
  page: Page,
  testId: string,
  option: string
) {
  await page.click(`[data-testid="${testId}"]`);
  await page.click(`[data-testid="${testId}-option-${option}"]`);
}

export async function interceptAndMock(
  page: Page,
  urlPattern: string | RegExp,
  response: any
) {
  await page.route(urlPattern, (route) => {
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify(response),
    });
  });
}

Configure GitHub Actions for parallel execution:

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run tests
        run: npx playwright test --shard=${{ matrix.shard }}/4

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report-${{ matrix.shard }}
          path: playwright-report/

Lessons Learned

  1. Never use waitForTimeout. Replace it with auto-waiting assertions that retry until the condition is met.

  2. Use separate browser contexts for test isolation. The storageState option in projects handles authentication state without sharing cookies.

  3. Run tests in parallel with sharding in CI to reduce total execution time. Four shards typically reduce time by 75%.

  4. Mock API responses for unit-like tests. Only use real API calls for integration tests that specifically test the API layer.

  5. Enable tracing on first retry to capture debug information without the overhead of recording every test run.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.