Playwright Advanced

Advanced Playwright patterns beyond basic locators and clicks: custom fixtures, API testing, tracing, code generation, CI optimisation, and the Healer agent for self-healing selectors.

Advanced Playwright patterns beyond basic locators and clicks: custom fixtures, API testing, tracing, code generation, CI optimisation, and the Healer agent for self-healing selectors.


Custom Fixtures

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

type MyFixtures = {
  authenticatedPage: Page;
  apiContext: APIRequestContext;
};

export const test = base.extend<MyFixtures>({
  // Authenticated page — logs in via API (fast) before each test
  authenticatedPage: async ({ page, request }, use) => {
    const response = await request.post('/api/auth/token', {
      data: { email: 'test@example.com', password: 'testpass' },
    });
    const { access_token } = await response.json();

    await page.context().addCookies([{
      name: 'auth_token',
      value: access_token,
      domain: 'localhost',
      path: '/',
    }]);

    await use(page);
  },

  // Shared API client for setup/teardown
  apiContext: async ({ playwright }, use) => {
    const context = await playwright.request.newContext({
      baseURL: 'http://localhost:3000',
      extraHTTPHeaders: {
        Authorization: `Bearer ${process.env.TEST_API_TOKEN}`,
      },
    });
    await use(context);
    await context.dispose();
  },
});

export { expect };
// tests/dashboard.spec.ts — using custom fixtures
import { test, expect } from './fixtures';

test('shows personalised greeting', async ({ authenticatedPage: page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: /Welcome/ })).toBeVisible();
});

API Testing in Playwright

import { test, expect } from '@playwright/test';

test.describe('Products API', () => {
  test('GET /api/products returns paginated list', async ({ request }) => {
    const response = await request.get('/api/products?page=1&pageSize=10');

    expect(response.status()).toBe(200);
    const body = await response.json();
    expect(body.data).toHaveLength(10);
    expect(body.pagination.totalItems).toBeGreaterThan(0);
    expect(body.pagination.nextCursor).toBeDefined();
  });

  test('POST /api/products creates a product', async ({ request }) => {
    const response = await request.post('/api/products', {
      data: { name: 'New Widget', price: 29.99, category: 'electronics' },
      headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
    });

    expect(response.status()).toBe(201);
    const product = await response.json();
    expect(product.data.id).toBeDefined();
    expect(product.data.name).toBe('New Widget');
  });

  test('returns 422 for invalid price', async ({ request }) => {
    const response = await request.post('/api/products', {
      data: { name: 'Bad Product', price: -10 },
      headers: { Authorization: `Bearer ${process.env.TEST_API_TOKEN}` },
    });

    expect(response.status()).toBe(422);
    const body = await response.json();
    expect(body.error.details).toContainEqual(
      expect.objectContaining({ field: 'price' })
    );
  });
});

Network Interception

// Intercept and mock network calls
test('shows error state when API fails', async ({ page }) => {
  await page.route('/api/products', route => route.fulfill({
    status: 500,
    json: { error: 'Internal Server Error' },
  }));

  await page.goto('/products');
  await expect(page.getByTestId('error-message')).toBeVisible();
});

// Spy on requests without stubbing
test('sends correct payload on checkout', async ({ page }) => {
  let orderPayload: unknown;
  await page.route('/api/orders', async route => {
    orderPayload = JSON.parse(route.request().postData() ?? '{}');
    await route.continue();
  });

  await page.goto('/checkout');
  await page.getByRole('button', { name: 'Place Order' }).click();
  await page.waitForResponse('/api/orders');

  expect(orderPayload).toMatchObject({ items: expect.any(Array) });
});

Tracing

// playwright.config.ts
export default defineConfig({
  use: {
    trace: 'on-first-retry',   // capture full trace on retry (flaky test diagnosis)
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});
# View trace for a failed test
npx playwright show-trace test-results/my-test/trace.zip

# Trace viewer shows: timeline, DOM snapshots, network calls, console

Codegen — Record Tests

# Start browser and record interactions as Playwright code
npx playwright codegen http://localhost:3000

# Generate in Python
npx playwright codegen --target python http://localhost:3000

# Record into a file
npx playwright codegen --output tests/recorded.spec.ts http://localhost:3000

Codegen produces a starting point. Always refactor to use data-testid locators and explicit waits.


Playwright Healer (v1.56+)

The Healer MCP integration auto-repairs broken locators using AI. When a test fails because a selector is stale, Healer:

  1. Takes a screenshot of the failing step
  2. Uses Claude to identify the correct new selector
  3. Opens a PR with the fix
  4. 75% first-attempt success rate
// healer.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    // Enable Healer in CI
    healer: process.env.CI ? 'autofix' : 'off',
  },
});

Parallel Execution and Sharding

// playwright.config.ts
export default defineConfig({
  workers: process.env.CI ? 4 : undefined,  // 4 parallel workers in CI
  fullyParallel: true,                       // run tests within a file in parallel
});
# Shard across multiple CI machines
# Machine 1
npx playwright test --shard=1/3

# Machine 2
npx playwright test --shard=2/3

# Machine 3
npx playwright test --shard=3/3

# Merge reports
npx playwright merge-reports --reporter html ./all-blob-reports

Common Failure Cases

Custom fixture not composed from the extended test object Why: a test file imports test from @playwright/test directly instead of from ./fixtures, so the custom authenticatedPage and apiContext fixtures are not available and the test receives undefined. Detect: TypeScript compiler error "Property 'authenticatedPage' does not exist on type TestInfo" or a runtime undefined page reference. Fix: ensure every test file that needs custom fixtures imports { test, expect } from the local ./fixtures file, not from @playwright/test.

page.route intercept set up after navigation Why: calling page.route('/api/products', ...) after page.goto('/products') means the route was not active when the initial requests fired, so the real endpoint was hit instead of the mock. Detect: the error state the test expects is never shown; the network tab in trace viewer shows the real API response. Fix: always call page.route() before page.goto() so the interceptor is registered before any requests are made.

Codegen-generated CSS locators break after a UI reskin Why: codegen defaults to CSS selectors like .checkout-button which are coupled to class names; a Tailwind class purge or component rename invalidates them. Detect: ElementHandle or Locator errors pointing to CSS selectors; the element is visible in the browser but not found by the test. Fix: refactor codegen output to use role-based or data-testid locators immediately after recording, before committing.

Healer opens PRs for intentional selector changes, not broken ones Why: when a developer deliberately renames a component and updates tests manually, Healer still detects the old selector as broken and opens a conflicting PR with the original name restored. Detect: a Healer PR attempts to revert a selector that was correctly updated in the same branch. Fix: disable Healer (healer: 'off') in branches where deliberate selector changes are being made, or close Healer PRs after confirming the manual update is correct.

Connections

tqa-hub · technical-qa/visual-testing · technical-qa/test-architecture · technical-qa/flaky-test-management · qa/cross-browser-testing · test-automation/playwright · llms/ae-hub

Open Questions

  • What is the most common failure mode when implementing this at scale?
  • How does this testing approach need to adapt for distributed or microservice architectures?