Advanced Snapshot Testing in Playwright
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. 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:
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:
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:
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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
:
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:
npx playwright test --project cross-browser -u
And then let's run the tests:
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.
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.
In the meantime, happy testing!