# Visual, Snapshot, and Accessibility Testing

Course: [Playwright](https://www.devtutorials.ai/learn/playwright) · Section: Test Design and Quality
Source: https://www.devtutorials.ai/learn/playwright/test-design/visual-snapshot-and-accessibility-testing

> Visual regression testing with toHaveScreenshot, baselines and --update-snapshots, masking dynamic content, thresholds and platforms, snapshot testing values with toMatchSnapshot, ARIA snapshots with ariaSnapshot and toMatchAriaSnapshot, automated accessibility scans with @axe-core/playwright, and keyboard checks axe cannot do.

## Visual, Snapshot, and Accessibility Testing

- A test can pass while the page looks broken. Text that turned white on white still "has the right text".
- **Visual tests** compare how the page looks with a saved picture.
- **Snapshot tests** compare values and page structure with a saved copy.
- **Accessibility tests** check that everyone can use the page, including people with screen readers.

**Simple lesson:** save what "good" looks like once. Then every run compares against it.

### A Real-World Example

- The Practice Shop's **Summer Sale** banner must look right. It has a live countdown and a visitor counter that change all the time.
- This test takes a screenshot of the banner and hides the parts that change.
- **Run it twice.** The first run has nothing to compare with, so it saves a picture and fails. The second run compares and passes.

The lab cannot paint real pixels. It compares every visible element with its text and main styles (colour, size, weight, layout). A real run compares PNG images pixel by pixel. The workflow, options and messages are the same.

### What You Will Learn

1. How visual tests work: baselines, actual images and diffs.
2. Updating baselines with `--update-snapshots`.
3. Hiding dynamic content, and allowing small differences.
4. Snapshot testing values with `toMatchSnapshot()`.
5. ARIA snapshots: checking the page's structure.
6. Accessibility scans with axe-core, and what they miss.

## Visual Testing with Screenshots

### Baselines, Actuals and Diffs

- The first time `toHaveScreenshot()` runs, there is no picture to compare with. Playwright saves the **actual** screenshot and fails with *"A snapshot doesn't exist ... writing actual."*
- That saved picture is the **baseline** (also called the golden image). You commit it to git.
- Every later run takes a new screenshot and compares. If too many pixels differ, the test fails and Playwright saves an **expected**, **actual** and **diff** image to look at.

<figure className="plw-fig">
<svg viewBox="0 0 640 220" role="img" aria-labelledby="fig-plw-visual" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-plw-visual">Animation: run 1 saves a baseline picture of the banner. Run 2 takes a new picture, the heading colour changed, and the diff image highlights the heading in red, so the test fails</title>
  <g style={{ fontFamily: "var(--sans)", fontSize: 12, fontWeight: 700 }}>
    <text x="95" y="22" textAnchor="middle" style={{ fill: "var(--ink2)" }}>Baseline (run 1)</text>
    <text x="320" y="22" textAnchor="middle" style={{ fill: "var(--ink2)" }}>Actual (run 2)</text>
    <text x="545" y="22" textAnchor="middle" style={{ fill: "var(--ink2)" }}>Diff</text>
  </g>
  <g>
    <rect x="15" y="35" width="160" height="110" rx="10" style={{ fill: "#fff4e5", stroke: "#f5c27a" }} />
    <text x="30" y="65" style={{ fill: "#9a3412", fontFamily: "var(--sans)", fontSize: 16, fontWeight: 800 }}>Summer Sale</text>
    <rect x="30" y="80" width="120" height="8" rx="4" style={{ fill: "#d6b48a" }} />
    <rect x="30" y="98" width="90" height="8" rx="4" style={{ fill: "#d6b48a" }} />
    <rect x="30" y="118" width="70" height="18" rx="5" style={{ fill: "#1f3a8a" }} />
  </g>
  <g opacity="0">
    <animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.3;0.35;0.95;1" dur="8s" repeatCount="indefinite" />
    <rect x="240" y="35" width="160" height="110" rx="10" style={{ fill: "#fff4e5", stroke: "#f5c27a" }} />
    <text x="255" y="65" style={{ fill: "#e11d48", fontFamily: "var(--sans)", fontSize: 16, fontWeight: 800 }}>Summer Sale</text>
    <rect x="255" y="80" width="120" height="8" rx="4" style={{ fill: "#d6b48a" }} />
    <rect x="255" y="98" width="90" height="8" rx="4" style={{ fill: "#d6b48a" }} />
    <rect x="255" y="118" width="70" height="18" rx="5" style={{ fill: "#1f3a8a" }} />
  </g>
  <g opacity="0">
    <animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.55;0.6;0.95;1" dur="8s" repeatCount="indefinite" />
    <rect x="465" y="35" width="160" height="110" rx="10" style={{ fill: "#f2f2f2", stroke: "#cccccc" }} />
    <rect x="478" y="50" width="105" height="20" rx="3" style={{ fill: "#ff0000", opacity: 0.75 }} />
    <text x="545" y="170" textAnchor="middle" style={{ fill: "var(--plw-red)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}>1,204 pixels differ ✘</text>
  </g>
  <text x="320" y="205" textAnchor="middle" style={{ fill: "var(--ink2)", fontFamily: "var(--sans)", fontSize: 12 }}>A CSS change turned the heading red. The diff shows exactly where.</text>
</svg>
<figcaption>The baseline is saved once. Each run compares a new picture with it, and the diff marks the changed pixels.</figcaption>
</figure>

### Your First Visual Test

- `await expect(page).toHaveScreenshot()` takes the whole page. `await expect(locator).toHaveScreenshot()` takes one element.
- Give the picture a name, like `'login-form.png'`. Without one, Playwright makes a name from the test title.
- Like other web-first assertions, it **retries**. It waits until two screenshots in a row are the same, then compares.
- Run this box twice. Watch the output: the first run writes the baseline.

- Real file names include the browser and the system: `login-form-chromium-linux.png`. Each browser and system gets its own baseline, because fonts render a little differently.

### Catching a Visual Bug

- Here, the second test adds a CSS rule, like a bad deploy would. Its screenshot uses the **same name** as the first test, so it is compared with the same baseline.
- Run it twice. The second test fails, and the output lists what changed.

- No text changed, so a `toHaveText()` check would still pass. Only a visual test catches this.
- The helper `masks()` keeps both tests hiding the same parts. `Page` is the type of the `page` fixture.

### Updating Baselines

- When the design changes on purpose, the old baseline is wrong. Update it:

```bash
npx playwright test --update-snapshots
```

- `-u` is the short form. It rewrites the baselines of the screenshots that changed.
- `--update-snapshots=missing` only writes baselines that do not exist yet, and the run passes. Handy for a new test on CI.
- `--update-snapshots=all` rewrites every baseline. `none` never writes any.
- Look at the new images before you commit them. An update can hide a real bug if nobody checks.

Never run `--update-snapshots` just to make a red build green. First open the HTML report, compare the expected, actual and diff images, and make sure the change was meant.

**Simple lesson:** the first run saves a baseline. Later runs compare. `-u` updates it on purpose.

## Making Screenshots Stable

### Masking Dynamic Content

- Clocks, counters, ads, random tips and dates change every run. They make screenshots fail for no reason.
- `mask: [locator, ...]` covers those elements with a solid box (pink by default) in both pictures.
- Without masks, the banner keeps changing, so Playwright cannot even get two equal screenshots. This box shows that error.

- Add `mask: [page.getByTestId('countdown'), page.getByTestId('visitors')]` and run again. It passes.

### Other Ways to Reduce Noise

- `animations: 'disabled'` stops CSS animations and transitions. It is the default for `toHaveScreenshot()`.
- `stylePath: 'tests/screenshot.css'` adds CSS only while the screenshot is taken. Use it to hide things with `visibility: hidden`.
- `maxDiffPixels: 100` allows up to 100 different pixels. `maxDiffPixelRatio: 0.01` allows 1 percent.
- `threshold: 0.2` (0 to 1) decides how different one pixel's colour must be to count.
- Set defaults for every screenshot in the config:

```ts
export default defineConfig({
  expect: {
    toHaveScreenshot: { maxDiffPixels: 100, animations: 'disabled' },
  },
});
```

### Same Machine, Same Pictures

- Fonts and anti-aliasing differ between Windows, macOS and Linux. A baseline from your laptop can fail on CI.
- Most teams make and check baselines in **one place**: CI, or the official Playwright Docker image. **CI/CD and Scaling** shows how.

**Simple lesson:** mask what changes, allow tiny differences, and make baselines on one kind of machine.

## Snapshot Testing Values

### toMatchSnapshot()

- `expect(value).toMatchSnapshot('name.txt')` works like a screenshot, but for **text**.
- Use it for long output that should not change by accident: an exported CSV, a generated email, an API response.
- The same rules apply: the first run writes the file, `-u` updates it.

## ARIA Snapshots

### What the Page Means, Not How It Looks

- Screen readers do not see colours. They read the **accessibility tree**: roles, names and states.
- `locator.ariaSnapshot()` prints that tree as YAML. Each line is `- role "name"`, with states like `[level=1]` or `[checked]`.

- The output is a good start for a test. Copy the lines you care about.

### toMatchAriaSnapshot()

- `await expect(locator).toMatchAriaSnapshot(template)` checks the tree against a YAML template.
- Matching is **partial**. List only what matters. Lines must appear in the same order, but other elements may sit between them.
- A name can be a regular expression: `- heading /Welcome back, \w+/`.
- It survives CSS changes and wrapper `div`s, but fails when the meaning changes: a missing label, a renamed button, a wrong heading level.

- Inside a JavaScript template string, a regex backslash is written twice: `\\w`.

**Simple lesson:** ARIA snapshots check what the page means. They ignore how it looks.

## Accessibility Testing with axe

### Installing and Running axe-core

- **axe-core** is a popular accessibility rule engine. `@axe-core/playwright` runs it inside your Playwright tests.
- Install it once: `npm install -D @axe-core/playwright`.
- `new AxeBuilder({ page }).analyze()` scans the page and returns `violations`. An empty list means no rule failed.

- The page has four real problems: an image with no `alt` text, an email field with no label, a close button with no name, and grey text that is too light to read.
- `/newsletter?fixed=1` is the same page with all four fixed. Change the URL and run again.

### Choosing What to Scan

- `.withTags(['wcag2a', 'wcag2aa'])` runs only the rules for those WCAG levels. Most teams aim for **WCAG 2 AA**.
- `.include('#main')` scans one part. `.exclude('.ads')` skips a part you do not own.
- `.disableRules(['color-contrast'])` turns one rule off. Use it rarely, with a comment and a ticket to fix it.

### What Automated Checks Cannot Find

- Tools find only part of the problems, often quoted as **about a third**. They cannot tell if alt text is helpful, or if the page makes sense with a keyboard.
- Add **keyboard tests**: press `Tab` and check where the focus goes with `toBeFocused()`.
- Nothing replaces trying the page yourself with a keyboard and a screen reader.

Locators like `getByRole()` and `getByLabel()` are a small accessibility check on their own. If a test cannot find a button by its role and name, a screen reader user may not find it either.

**Simple lesson:** axe finds common problems fast. Keyboard tests and real people find the rest.

## Try It in Your Project

- These steps take the lesson from the lab to your own app.
- Add one `toHaveScreenshot()` for your most visual page, with masks for dates and ads.
- Generate baselines in CI or Docker, not on your laptop.
- Add an axe scan for your key pages and fix the critical violations first.

**Simple lesson:** use one idea from this lesson on your own app this week.

## TL;DR

- `toHaveScreenshot()` compares with a baseline. The first run writes it and fails.
- `--update-snapshots` (`-u`) updates baselines. Check the images before you commit.
- `mask`, `stylePath`, `maxDiffPixels` and `threshold` remove noise. Make baselines on one kind of machine.
- `toMatchSnapshot()` does the same for text.
- `ariaSnapshot()` prints the accessibility tree. `toMatchAriaSnapshot()` checks it, partially.
- `new AxeBuilder({ page }).analyze()` finds common accessibility bugs. Keyboard tests cover more.

## Common mistakes

| Mistake | What happens | Fix |
|---|---|---|
| Running `-u` whenever a screenshot fails | Real visual bugs become the new baseline | Look at the diff first |
| Screenshots of pages with clocks or counters | Tests fail on every run | Mask the changing parts |
| Baselines made on a laptop, checked on Linux CI | Font differences fail every test | Make baselines in CI or Docker |
| Screenshot tests for everything | Slow, noisy suites | A few key screens. Use assertions for the rest |
| Treating zero axe violations as "accessible" | Many problems stay hidden | Add keyboard tests and manual checks |

## Check Yourself

## Exercises

## What to Remember

- Visual tests: baseline first, compare after, `-u` to update on purpose.
- Mask what changes. Make baselines on one kind of machine.
- `toMatchSnapshot()` for text, `toMatchAriaSnapshot()` for structure.
- axe-core finds common accessibility bugs. Keyboard tests and people find the rest.

Next: **Debugging and Tracing**
