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.