Skip to main content
Playwright advanced Lesson 10 of 10

Visual Comparison and API Testing

Pixel and ARIA snapshots that do not fail on every deploy, plus using the request fixture to seed data over HTTP and test an API without a browser.

The same runner does two things beyond clicking: it compares rendered output against a committed baseline, and it makes HTTP requests directly. Both are most useful in support of your UI tests rather than as separate suites.

A first screenshot assertion

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

test('todo list looks right', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');
  await input.fill('buy milk');
  await input.press('Enter');

  await expect(page).toHaveScreenshot('todo-with-one-item.png');
});
  ✘  1 [chromium] › tests/visual.spec.ts:3:1 › todo list looks right (1.6s)

    Error: A snapshot doesn't exist at
    tests/visual.spec.ts-snapshots/todo-with-one-item-chromium-linux.png, writing actual.

  1 failed

The first run always fails: there was nothing to compare against, so it wrote the baseline. Run again and it passes:

  ✓  1 [chromium] › tests/visual.spec.ts:3:1 › todo list looks right (1.3s)

  1 passed (2.0s)

Note the filename — -chromium-linux. Baselines are per project and per platform because the pixels genuinely differ between operating systems. A baseline generated on macOS will fail on a Linux CI runner every time.

What a real diff looks like

Change a padding value in the app and rerun:

  ✘  1 [chromium] › tests/visual.spec.ts:3:1 › todo list looks right (2.1s)

    Error: Screenshot comparison failed:

      2841 pixels (ratio 0.02 of all image pixels) are different.

    Expected: tests/visual.spec.ts-snapshots/todo-with-one-item-chromium-linux.png
    Received: test-results/visual-todo-list-looks-right-chromium/todo-with-one-item-actual.png
        Diff: test-results/visual-todo-list-looks-right-chromium/todo-with-one-item-diff.png

Three images: expected, actual, and a diff with the changed pixels highlighted. The HTML report shows them side by side with a slider, which is the fastest way to judge whether the change was intended.

Accept it with:

npx playwright test --update-snapshots
  ✓  1 [chromium] › tests/visual.spec.ts:3:1 › todo list looks right (1.4s)

  1 passed (2.1s)
    1 snapshot updated.

Then read the PNG diff in the pull request before approving. Blind updates are how visual tests stop catching anything.

Making them survive real pages

Out of the box, screenshot tests fail on anything dynamic — a timestamp, an avatar, a carousel mid-animation. Four settings do most of the work:

test('dashboard', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page).toHaveScreenshot('dashboard.png', {
    fullPage: true,
    animations: 'disabled',                     // freeze CSS animations at their end state
    mask: [page.getByTestId('last-updated'), page.getByRole('img', { name: 'avatar' })],
    maxDiffPixelRatio: 0.01,                    // tolerate 1% noise
  });
});
  ✓  1 [chromium] › tests/visual.spec.ts:14:1 › dashboard (2.4s)

  1 passed (3.1s)

mask paints the named elements a flat colour before comparing, which is better than excluding them: the layout is still checked, only the contents are ignored.

Set the defaults once rather than per test:

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled' },
  },
});

Screenshot a component rather than the page when you can — a locator screenshot is stable against changes anywhere else on the page:

await expect(page.getByRole('list')).toHaveScreenshot('todo-list.png');
  ✓  1 [chromium] › tests/visual.spec.ts:22:1 › list only (1.2s)

ARIA snapshots

Pixel comparison answers “did it move”. The accessibility tree answers “is it still the same thing”, and it does not care about colour, font or spacing:

test('todo structure', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');
  await input.fill('buy milk');
  await input.press('Enter');

  await expect(page.getByRole('main')).toMatchAriaSnapshot(`
    - list:
      - listitem:
        - checkbox
        - text: buy milk
  `);
});
  ✓  1 [chromium] › tests/visual.spec.ts:30:1 › todo structure (1.2s)

  1 passed (1.9s)

Turn the <li> into a styled <div> and it fails with a readable diff:

    Error: expect(locator).toMatchAriaSnapshot(expected)

    - Expected  - 3
    + Received  + 2

    - - list:
    -   - listitem:
    -     - checkbox
    + - generic:
    +   - checkbox
        - text: buy milk

That is a real accessibility regression, caught in text, with no baseline image to maintain. For most teams this is the better default and pixel snapshots are the specialist tool.

Generate the YAML rather than writing it by hand:

npx playwright test --update-snapshots

with toMatchAriaSnapshot() left empty, or copy it from the trace viewer’s ARIA tab.

Testing an API directly

The request fixture is an HTTP client with the context’s cookie jar attached:

test('orders API rejects a negative total', async ({ request }) => {
  const response = await request.post('/api/orders', {
    data: { ref: 'A-1004', total: -5 },
  });

  expect(response.status()).toBe(422);
  expect(await response.json()).toMatchObject({
    error: 'total must be positive',
  });
});
  ✓  1 [chromium] › tests/api.spec.ts:3:1 › orders API rejects a negative total (94ms)

  1 passed (612ms)

94ms, no browser. Note toMatchObject rather than toEqual — it asserts the fields you care about and ignores the ones the API is free to add.

For a standalone API project, drop the browser entirely:

// playwright.config.ts
projects: [
  {
    name: 'api',
    testMatch: /.*\.api\.spec\.ts/,
    use: {
      baseURL: 'http://localhost:3000',
      extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
    },
  },
],
Running 8 tests using 4 workers

  ✓  1 [api] › tests/orders.api.spec.ts:4:1 › lists orders (61ms)
  ✓  2 [api] › tests/orders.api.spec.ts:9:1 › creates an order (88ms)

  8 passed (1.2s)

The combination that pays off

Seed over HTTP, assert in the browser. This is the highest-value use of the request fixture in an end-to-end suite:

test('an order created by the API appears in the UI', async ({ page, request }) => {
  const created = await request.post('/api/orders', {
    data: { ref: 'A-2001', total: 42.0 },
  });
  expect(created.ok()).toBeTruthy();
  const { id } = await created.json();

  await page.goto('/orders');
  await expect(page.getByRole('row', { name: /A-2001/ })).toBeVisible();

  await request.delete(`/api/orders/${id}`);
});
  ✓  1 [chromium] › tests/orders.spec.ts:5:1 › an order created by the API appears in the UI (1.1s)

  1 passed (1.8s)

Creating that order through the UI would have taken six actions and four seconds, and it would have been testing the create form for the twentieth time. Test each flow through the UI once; set up everything else over HTTP.

Because request shares cookies with page, a session established by the storage state from lesson 6 authenticates these calls too — no separate token handling.

Practice

1. Take a locator screenshot and change something outside it. Does the test fail?
await expect(page.getByRole('list')).toHaveScreenshot('list.png');
  ✓  1 [chromium] › tests/visual.spec.ts:44:1 › list only (1.2s)

  1 passed (1.9s)

It passes — a locator screenshot captures only that element’s box. This is why component snapshots are far less noisy than full-page ones, and why a full-page baseline tends to be updated so often that nobody reads the diff.

2. Mask a timestamp element and confirm the comparison stops failing.
await expect(page).toHaveScreenshot('dashboard.png', {
  mask: [page.getByTestId('last-updated')],
});
  ✓  1 [chromium] › tests/visual.spec.ts:52:1 › dashboard (2.2s)

  1 passed (2.9s)

The masked region is filled with a solid colour in both the baseline and the actual image, so its content is ignored while its size is still compared — a timestamp that wraps onto two lines still fails, which is usually what you want.

3. Write an ARIA snapshot for the TodoMVC header and break it.
await expect(page.getByRole('banner')).toMatchAriaSnapshot(`
  - heading "todos" [level=1]
  - textbox "What needs to be done?"
`);
    Error: expect(locator).toMatchAriaSnapshot(expected)

    - Expected  - 1
    + Received  + 1

    - - heading "todos" [level=1]
    + - heading "todos" [level=2]
      - textbox "What needs to be done?"

Heading level is part of the accessibility tree, so a change from h1 to h2 fails — an SEO and screen-reader regression that no pixel diff would flag, since the two often render identically once CSS is applied.

4. Use the request fixture to delete test data in an afterEach.
const created: string[] = [];

test.afterEach(async ({ request }) => {
  for (const id of created.splice(0)) {
    await request.delete(`/api/orders/${id}`);
  }
});
  ✓  1 [chromium] › tests/orders.spec.ts:12:1 › creates an order (1.2s)
  ✓  2 [chromium] › tests/orders.spec.ts:19:1 › lists orders (743ms)

  2 passed (2.6s)

afterEach runs even when the test fails, so a failing test does not leave rows behind for the next run to trip over. splice(0) empties the array as it reads it, which keeps the list correct when tests run in parallel within a worker.

That closes the Playwright track. The pattern underneath all ten lessons is the same one: assert on state rather than on timing, and let the framework do the waiting.

Frequently Asked Questions

Why do screenshot tests fail on CI but pass locally?
Font rendering and GPU compositing differ between operating systems, so the pixels genuinely differ. Playwright names baselines per platform for this reason; generate CI baselines inside the same container image CI uses, usually via the official Playwright Docker image.
How do I update a Playwright screenshot baseline?
Run with `--update-snapshots`, then review the changed PNGs in the diff before committing. Updating without looking is how an unnoticed layout regression becomes the new expected state.
What is an ARIA snapshot?
`toMatchAriaSnapshot` captures the accessibility tree — roles, names and structure — as YAML instead of pixels. It catches a heading that became a div or a lost button label, and unlike a screenshot it does not change when a colour or a font does.
Can Playwright test an API without opening a browser?
Yes. The `request` fixture is a full HTTP client with cookie handling, so a spec can do request/response testing with no page at all. Its main use in an end-to-end suite is seeding and cleaning up data far faster than driving the UI.