Advanced Snapshot Testing in Playwright
Tags: playwright, javascript, testing, ux
Published: 2024-03-05 00:00:00

Playwright's snapshot assertions are an incredibly powerful tool for ensuring your app's UI remains consistent across code changes, browsers, and devices. But they're not always easy to use.

This article dives deep on snapshot testing in Playwright, covering a wide range of features and techniques. By the end, you'll be a snapshot testing master, ensuring your app's flawless visual consistency across browsers and devices.

When you're finished, check out my [Ultimate Guide to Visual Testing with Playwright](https://www.browsercat.com/post/ultimate-guide-visual-testing-playwright). It covers setup, advanced configuration, and running your visual tests in CI/CD.

Let's go!

## Page vs. Element Snapshots

Playwright's visual testing API allows you to take snapshots of the entire page or just a specific element.

But when is the right time to use one over the other?

### When should I use page snapshots?

Page snapshots are excellent for verifying the entire page works as expected. Use page snapshots to test layout, responsiveness, and accessibility.

But be warned: Page snapshots can be flaky. After all, if anything within the viewport changes, the entire snapshot will fail. We'll cover strategies for minimizing these effects later, but for now, it's wise to consider page snapshots a powerful, blunt instrument.

You've already seen a page snapshot in action. Here's a refresher:

```typescript
test('page snapshot', async ({page}) => {
  await page.goto('https://www.browsercat.com');
  await expect(page).toHaveScreenshot();
});
```

### When should I use element snapshots?

Element snapshots, as you expect, focus exclusively on a single page element. This makes them an excellent choice for testing components in isolation, or for verifying an element behaves as expected within a certain context.

Element snapshots are substantially less brittle than page snapshots, but they require a bit more overhead to set up. After all, their narrow targeting means you need more of them to cover the same surface area as a page snapshot.

Here's an example of an element snapshot:

```typescript
test('element snapshot', async ({page}) => {
  await page.goto('https://www.browsercat.com');
  const $button = page.locator('button').first();
  await expect($button).toHaveScreenshot();
});
```

## Working with Page Snapshots

Let's explore some useful features and common use-cases for page snapshots...

### Cropping Page Snapshots

Sometimes the entire viewport isn't necessary to prove your test passes. And sometimes a portion of the viewport changes frequently by design, turning an otherwise great test into a flake.

In these cases, it's best to crop your snapshot to the area of interest. Here's an example:

```typescript
test('cropped snapshot', async ({page}) => {
  await page.goto('https://www.browsercat.com');
  const {width, height} = page.viewportSize();

  await expect(page).toHaveScreenshot({
    // square at the center of the page
    clip: {
      x: (width - 400) / 2, 
      y: (height - 400) / 2, 
      width: 400, 
      height: 400,
    },
  });

  await expect(page).toHaveScreenshot({
    // top slice, maximum possible width
    clip: {x: 0, y: 0, width: Infinity, height: 16},
  });
});
```

### Snapshot the Entire Page

By default, Playwright takes a snapshot of the current viewport. This is typically what you want, as the larger the snapshot is, the more likely it is for your test to fail.

However, full page snapshots have their place. For example, if you're testing that a page looks the same across different browsers, the the easiest solution is to snapshot the entire page. And since for this kind of test, you aren't storing the snapshot from previous runs, you're not going to end up with an overly flaky test.

Here's how you take a full page snapshot:

```typescript
test('full page snapshot', async ({page}) => {
  await page.goto('https://www.browsercat.com');
  await expect(page).toHaveScreenshot({
    fullPage: true,
  });
});
```

### Scroll Before Taking a Page Snapshot

When working with page snapshots, you'll often want to scroll the page before the visual assertion.

Here's how:

```typescript
test('scroll before snapshot', async ({page}) => {
  await page.goto('https://www.browsercat.com');

  await page.evaluate(() => {
    document
      .querySelector('#your-element')
      ?.scrollIntoView({behavior: 'instant'});
  });

  await expect(page).toHaveScreenshot();
});
```

Note: While Playwright has a `.scrollIntoViewIfNeeded()` method, it will not scroll the element to the top of the viewport. So I recommend the solution above. It will make full use of your viewport and ensure your snapshot is consistent between runs.

## Working with Element Snapshots

Element snapshots are much more "in the weeds" than page snapshots. They bring a lot of power and flexibility.

Let's explore some examples...

### Test Element Interactivity

As your component library grows, it becomes harder and harder to keep track of every state of every element in your library.

In the following example, we snapshot a form input across various states:

```typescript
test('element states', async ({page}) => {
  await page.goto('https://www.browsercat.com/contact');
  const $textarea = page.locator('textarea').first();

  await expect($textarea).toHaveScreenshot();
  await $textarea.hover();
  await expect($textarea).toHaveScreenshot();
  await $textarea.focus();
  await expect($textarea).toHaveScreenshot();
  await $textarea.fill('Hey, cool cat!');
  await expect($textarea).toHaveScreenshot();
});
```

### Test Element Responsiveness

When working with responsive designs, it's important to ensure your elements look good across the full range of screen sizes.

Use element snapshots to ensure your components look good at various breakpoints.

```typescript
test('element responsiveness', async ({page}) => {
  const viewportWidths = [960, 760, 480];
  await page.goto('https://www.browsercat.com/blog');
  const $post = page.locator('main article').first();

  for (const width of viewportWidths) {
    await page.setViewportSize({width, height: 800});
    await expect($post).toHaveScreenshot(`post-${width}.png`);
  }
});
```

## Advanced Snapshot Techniques

Page and element snapshots share many common configuration options. Let's explore the most useful among them...

### Masking Portions of a Snapshot

Sometimes, you'll want to exclude certain portions of a snapshot. A sub-element or sub-region may change frequently, contain sensitive information, or be irrelevant to the test. For example, a timestamp, an animation, a user email address, or a rotating ad.

Playwright provides the ability to "mask" these areas, replacing them with a consistent bright color unlikely to be confused for the content of your site.

Here's an example:

```typescript
test('masked snapshots', async ({page}) => {
  await page.goto('https://www.browsercat.com');
  const $hero = page.locator('main > header');
  const $footer = page.locator('body > footer');

  await expect(page).toHaveScreenshot({
    mask: [
      $hero.locator('img[src$=".svg"]'),
      $hero.locator('a[target="_blank"]'),
    ],
  });
  
  await expect($footer).toHaveScreenshot({
    mask: [
      $footer.locator('svg'),
    ],
  });
});
```

And here's what the first masked snapshot looks like:

![Masked snapshot](https://res.cloudinary.com/browsercat/image/upload/v1709054369/articles/pw-snapshot-masked.png)

### Keeping Styles Constant During Snapshots

Visual tests are valuable because they catch unexpected changes to your app's appearance. Some page elements are too unreliable to include as-is.

Thankfully, we can include some basic CSS for the duration of a snapshot that restrains or hides troublesome elements from the page.

Here's how:

```typescript
test('consistent styles', async ({page}) => {
  await page.goto('https://www.browsercat.com');
  const $hero = page.locator('main > header');

  await expect(page).toHaveScreenshot({
    stylePath: [
      './hide-dynamic-elements.css',
      './disable-scroll-animations.css',
    ],
  });
  
  await expect($hero).toHaveScreenshot({
    stylePath: [
      './hide-dynamic-elements.css',
      './disable-scroll-animations.css',
    ],
  });
});
```

### Auto-Retry Flaky Snapshots

When working with animations or dynamic content, your visual tests can become flaky. Large page snapshots are particularly susceptible.

Playwright can automatically retry failed visual tests for a certain duration, until it finds a valid match. Enable the feature like so:

```typescript
test('retry snapshots', async ({page}) => {
  await page.goto('https://www.browsercat.com');
  const $hero = page.locator('main > header');

  await expect(page).toHaveScreenshot({
    // retry snapshot until timeout is reached
    timeout: 1000 * 60,
  });

  await expect($hero).toHaveScreenshot({
    // retry snapshot until timeout is reached
    timeout: 1000 * 60,
  });
});
```

### Visual Tests for Generated Images

99.9% of the time, page and element snapshots will cover your use-case. But there are times when you'll want to assert an arbitrary image is consistent across test runs.

For example, perhaps your application generates QR codes or social share cards. Or perhaps you compress and transform user-uploaded avatars. You'll want to ensure this functionality doesn't break.

Use `expect().toMatchSnapshot()` for this:

```typescript
import {test, expect} from '@playwright/test';
import {buffer} from 'stream/consumers';

test('arbitrary snapshot', async ({page}) => {
  // generates custom avatars — fun!
  await page.goto('https://getavataaars.com');
  await page.locator('main form button').first().click();

  // download the avatar
  const avatar = await page.waitForEvent('download')
    .then((dl) => dl.createReadStream())
    .then((stream) => buffer(stream));

  expect(avatar).toMatchSnapshot('avatar.png');
});
```

### Compare Snapshots Across Browsers

All of the tests we've written thus far compares the state of your app before and after code changes. But what if you want to compare the state of your app across different browsers and devices?

To accomplish this, we're going to lean on Playwright's "projects" functionality. Projects allow you to define custom test suites with unique configuration. In a mature codebase, you may have quite a lot of these for different devices, environments, and testing strategies.

Let's make some magic!

First, update your `playwright.config.ts`. If you don't have one yet, create it at the root of your project:

```typescript
const crossBrowserConfig = {
  testDir: './tests/cross-browser',
  snapshotPathTemplate: '.test/cross/{testFilePath}/{arg}{ext}',
  expect: {
    toHaveScreenshot: {maxDiffPixelRatio: 0.1},
  },
};

export default defineConfig({
  // other config here...

  projects: [
    {
      name: 'cross-chromium',
      use: {...devices['Desktop Chrome']},
      ...crossBrowserConfig,
    },
    {
      name: 'cross-firefox',
      use: {...devices['Desktop Firefox']},
      dependencies: ['cross-chromium'],
      ...crossBrowserConfig,
    },
    {
      name: 'cross-browser',
      use: {...devices['Desktop Safari']},
      dependencies: ['cross-firefox'],
      ...crossBrowserConfig,
    },
  ],
});
```

Notice how we're specifically configuring the `snapshotPathTemplate` to store the snapshots for _all_ browsers in the _same_ location. This will ensure that each test compares its snapshots to the same source images.

Next, create a new test file at `./tests/cross-browser/homepage.spec.ts`:

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

test('cross-browser snapshots', async ({page, }) => {
  await page.goto('https://www.browsercat.com');
  await page.locator(':has(> a figure)')
    .evaluate(($el) => $el.remove());

  await expect(page).toHaveScreenshot(`home-page.png`, {
    fullPage: true,
  });
});
```

To avoid a fail, let's initialize our new snapshots:

```bash
npx playwright test --project cross-browser -u
```

And then let's run the tests:

```bash
npx playwright test --project cross-browser
```

Did all of your tests pass? Depending on your environment, they may not have! Different browsers render fonts, colors, and images differently, even when your app is functioning as expected.

If your tests failed, you may need to tweak the `maxDiffPixelRatio` and `threshold` options for your snapshots. If you want to debug this issue right now, visit my _Ultimate Guide to Visual Testing with Playwright_, and learn how to [make visual tests more forgiving](https://www.browsercat.com/post/ultimate-guide-visual-testing-playwright#make-visual-tests-more-forgiving).

## Next Steps...

You're getting pretty good at this! But there's still more to learn. For advice on fine-tuning your snapshot tests and running visual tests in CI/CD, check out my [Ultimate Guide to Visual Testing with Playwright](https://www.browsercat.com/post/ultimate-guide-visual-testing-playwright).

In the meantime, happy testing!