Setting up Playwright visual regression testing in Next.js

Jun 28, 2025 Loading...

Setting up Playwright

First install Playwright with npm i @playwright/test

Then add some scripts to run playwright, open the test runner, and update the stored screenshots:

"playwright": "playwright test",
"playwright-open": "playwright test --ui"
"playwright-update": "playwright test --update-snapshots",

Playwright env variable and disable next.js devIndicators

Set in playwright.config.ts a new env variable:

webServer: {
    command: 'npm run dev',
    url: devServerUrl,
    reuseExistingServer: !process.env.CI,
    env: {
      NEXT_PUBLIC_E2E_TESTING: 'true',
    },
  },

Then we can reference that env variable in next.config.ts to disable dev indicator (as it animates causing issues in playwright):

  ...(process.env.NEXT_PUBLIC_E2E_TESTING === 'true' ? { devIndicators: false } : {}),
 

Creating your Playwright test file

We then need to create a test that uses the toHaveScreenshot() method. Below is my test file where I loop through all my pages and take a screenshot:

import { blogPosts } from '@/constants/blogPosts'
import test, { expect } from '@playwright/test'
import { goToPath } from './helpers'
 
const blogPostPaths = blogPosts.map(post => '/blog/' + post.slug)
const allPages = ['/', '/blog/', ...blogPostPaths]
 
test.describe('page screenshots', () => {
  for (const path of allPages) {
    test(`${path}`, async ({ page }) => {
      await goToPath(page, path)
      await expect(page).toHaveScreenshot({
        fullPage: true,
        mask: [
          page.locator('.ss-hidden'),
          page.locator('img[src*=".gif"]'),
          page.locator('iframe'),
        ],
      })
    })
  }
})

I also use the mask property to ignore various elements that might not be presented consistently, like iframes and gifs.

I also added a .ss-hidden helper class I can wrap anything in I want hidden from the screenshot tool - like my blog post view count.

Running the tets and updating snapshots

Now run npm run playwright, note this will show an error when it is run for the first time, as these snapshots did not previously exist.

Then in future when you run playwright and it captures new snapshots and detects changes compared to the old snapshots the tests will fail and show you an image diff.

Then take a look at the diff and if you are happy with the changes run npm run playwright-update to update the snapshots 🚀

Disabling animations in playwright

Animations in playwright can be problematic, so to disable animations in playwright, you have two options depending on if your animations are CSS based or JS based.

  1. CSS based animations: use prefers-reduced-motion media queries and tell playwright to use reduced motion.
  2. JS based animations: disable animations using the NEXT_PUBLIC_E2E_TESTING env variable.

CSS based animations: Use prefers-reduced-motion media queries

First set prefers-reduce-motion: reduced in playwright by updating the playwright.config.ts:

projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
				contextOptions: { reducedMotion: 'reduce' },
      },
    },

and use media queries to only apply animations if users have no preference:

@media screen and (prefers-reduced-motion: no-preference) {
  animation: ...;
}

In TailwindCSS you can use motion-safe media query:

<div className="motion-safe:animate-[slide-up_0.3s_ease-in-out_forwards]" />

Now when playwright runs, it will not have the animations applied to these elements.

JS based animations: Disable animations using the env variable

I like to have this little helper variable:

export const isPlaywright = process.env.NEXT_PUBLIC_E2E_TESTING === 'true'

And it can be used in various ways. In the below I’m only applying framer motion animations if the server is not run from the playwright test runner.

import { isPlaywright } from '@/helpers/constants'
import * as motion from 'motion/react-client'
 
export const PageAnimation = (props: { children: React.ReactNode }) => {
  const { children } = props
  return (
    <motion.div
      {...(!isPlaywright && {
        initial: { opacity: 0, y: 10 },
        animate: { opacity: 1, y: 0 },
        transition: { duration: 0.28, type: 'spring', bounce: 0.1 },
      })}
    >
      {children}
    </motion.div>
  )
}

In this component below I have a Marquee component that animates some logos on my home page. .marquee-item has the animation applied to it (using pigmentCSS styles). I’m overwriting those styles using a TailwindCSS class.

export const isPlaywright = process.env.NEXT_PUBLIC_E2E_TESTING === 'true'
 
...
 
<div className="marquee-container">
  <div className="marquee-wrapper">
    <div className={clsx('marquee-item', isPlaywright && '!animate-none')}>{children}</div>
    <div className={clsx('marquee-item', isPlaywright && '!animate-none')}>{children}</div>
  </div>
</div>

If I was using CSS-in-JS I could reference isPlaywright directly in my CSS, but pigmentCSS is a build time static css generator and does not support dynanmic values in CSS.

If you like React, Next.js or front-end development in general, feel free to follow and say hi on Twitter @_AshConnolly! 👋 🙂