Visual regression testing, explained with pictures
A visual regression test compares a new screenshot of a page with an approved one and shows you what moved. Here is one, on a miniature page: the same 1280 x 720 screenshot three times.
- 1BaselineThe approved screenshot.
- 2New buildThree things changed. Can you see them?
- 3DiffThe changed pixels, in red.
- A missing icon. 576 changed pixels.
- A button moved 8 pixels. 768 changed pixels.
- A heading wrapped to two lines. 20,800 changed pixels.
What is visual regression testing?
Visual regression testing is an automated check that a page still looks the way it was approved to look. A tool opens the page in a browser, takes a screenshot and compares it with a stored screenshot of the same page, called the baseline. When the two differ by more than a set tolerance, the test fails and shows a diff: an image with the changed pixels painted in a strong colour.
A regression is something that used to work and no longer does. Unit tests and end-to-end tests check what a page does. They do not notice that a change to one stylesheet pushed a button under an image on the pricing page. A screenshot does, because it records everything the browser drew.
The comparison itself is simple. The hard part of visual testing is making two screenshots of an unchanged page come out identical, and most of this guide is about that.
Try it: how much tolerance hides a real bug?
A pixel budget that is loose enough to ignore rendering noise can also hide a real change. Slide between the two screenshots, then raise the budget and watch the three differences stop counting, smallest first.
Overlay: slide between the two screenshots
Left of the line is the baseline, right of it the new build. Watch the heading, the button and the middle card.
Tolerance: how many pixels may differ?
Nothing may differ. This is where Playwright starts when no budget is set.
- A missing icon576 pxCaught
- A button moved 8 pixels768 pxCaught
- A heading wrapped to two lines20,800 pxCaught
3 of 3 differences would fail a test on their own. This build still fails: together they change 22,144 pixels, 2.4% of the frame.
| Difference | Changed pixels | Share of the frame | Budget that hides it |
|---|---|---|---|
| A missing iconThe 24 x 24 icon of the middle card did not load. | Changed pixels24 x 24 = 576 | Share of the frame0.063% | Budget that hides it576 or more |
| A button moved 8 pixelsThe 160 x 48 button sits 8 pixels further right, so a strip changes on each side of it. | Changed pixels2 x 8 x 48 = 768 | Share of the frame0.083% | Budget that hides it768 or more |
| A heading wrapped to two linesThe first line lost 240 x 40 pixels of text and a second line of 280 x 40 appeared under it. | Changed pixels240 x 40 + 280 x 40 = 20,800 | Share of the frame2.26% | Budget that hides it20,800 or more |
| All three together | Changed pixels22,144 | Share of the frame2.40% | Budget that hides it22,144 or more |
The frame is 1280 x 720 = 921,600 pixels, and the counts are arithmetic on the solid shapes of the drawing. A ratio of 0.01 allows 921,600 x 0.01 = 9,216 pixels, which is 16 missing icons of 24 x 24.
How does a visual regression test run?
A visual regression test runs in six stages, and only the last two need a person. The first four are the same on every run, which is what makes the result comparable.
- 1
Render the page
Open the page or component in a real browser, at a fixed window size, with fixed data.
- 2
Take the screenshot
Wait until the page has stopped changing, then capture it.
- 3
Compare with the baseline
Load the approved screenshot of the same page, browser and platform.
- 4
Compute the diff
Count the pixels that differ by more than the colour threshold and draw them on a diff image.
- 5
Pass or review
Within the allowed number of pixels the test passes. Over it, a person looks at the diff.
- 6
Approve a new baseline
An intended change replaces the baseline. An unintended one is a bug to fix.
In Playwright, stages 1 to 5 are one assertion. It keeps taking screenshots until two in a row are the same, then compares the last one with the stored file.[2] The first run has nothing to compare with, so it writes the screenshot as the new golden file.[1]
How does screenshot comparison work?
Screenshot comparison is a pixel-by-pixel check with two tolerances on top: how different one pixel may be, and how many pixels may be different. Playwright does it with the pixelmatch library.[1]
Baseline
New
8 differ
Pixel-by-pixel comparison
Both screenshots are decoded to raw pixels and each pixel is compared with the pixel at the same position in the other image. The two images must have the same width and height.
pixelmatch returns the number of mismatched pixels and can write a diff image.[3]
Counts as the sameCounts as different
Per-pixel colour threshold
Two pixels count as different only when their colours are further apart than a threshold between 0 (strict) and 1 (lax). The threshold is about one pixel, not about a share of the image.
threshold: 0.2 by default in Playwright, 0.1 by default in pixelmatch itself.[2][3]3 of 100: ratio 0.03
Allowed count or ratio of different pixels
The test passes when the number of different pixels stays within a budget, given as a count or as a share of all pixels. A ratio grows with the image: 0.01 of a 1280 x 720 screenshot is 9,216 pixels.
maxDiffPixelsandmaxDiffPixelRatioin Playwright, both unset by default.[1][2]An edge
Not counted
Anti-aliasing detection
The half-shaded pixels that smooth the edge of a letter or a curve differ slightly from one render to the next. The comparison recognises them from their neighbours and leaves them out of the count.
pixelmatch ignores anti-aliased pixels unless
includeAAis true, and draws them yellow in the diff.[3]Scored window by window
Perceptual and structural methods
The image is compared in small windows or as a layout instead of as single pixels. SSIM (structural similarity) moves a window over both images and scores how alike each window is.
comparisonMethod: "ssim"in jest-image-snapshot. Applitools Eyes has match levels such as Strict and Layout.[4][10]
The Playwright options that decide a comparison
These are options of toHaveScreenshot, with the defaults from the Playwright documentation. Each can be set per assertion, or once under expect.toHaveScreenshot in the config.[1]
| Option | Default | What it does |
|---|---|---|
| threshold | Default: 0.2 | Acceptable perceived colour difference between the same pixel in both images, from 0 (strict) to 1 (lax).[2] |
| maxDiffPixels | Default: unset | How many pixels may differ. threshold decides which pixels count as different.[1][2] |
| maxDiffPixelRatio | Default: unset | Share of all pixels that may differ, between 0 and 1.[2] |
| animations | Default: "disabled" | Stops CSS animations, CSS transitions and Web Animations. Finite ones are fast-forwarded to their end, infinite ones are reset to their initial state.[2] |
| caret | Default: "hide" | Hides the blinking text cursor.[2] |
| scale | Default: "css" | One image pixel per CSS pixel, so a high-density screen does not change the image size.[2] |
| mask | Default: none | Locators to cover with a box (pink #FF00FF unless maskColor is set) before the capture.[2] |
| stylePath | Default: none | A stylesheet applied while the screenshot is taken, to hide or freeze volatile elements. It reaches into Shadow DOM and inner frames.[1][2] |
| fullPage | Default: false | Captures the whole scrollable page instead of the visible window.[2] |
Two thresholds, two meanings
threshold is about one pixel: how far its colour may be from the baseline. It is not a share of the image. To let a share of the image differ, use a count or a ratio of pixels. The pixelmatch README makes the same point: its threshold is a per-pixel colour difference where 1 is the distance between black and white.[3]Why are visual regression tests flaky?
A visual regression test is flaky when two screenshots of the same code come out different. The comparison is rarely at fault: the page or the machine drew something else. Each cause leaves its own pattern in the diff, so the diff tells you which fix to reach for.
| Cause | What the diff looks like | Fix |
|---|---|---|
| Font rendering differs between operating systems | What the diff looks likeA thin outline of changed pixels around every letter on the page. Sometimes a line breaks at a different word and everything under it moves. | FixCreate baselines and run comparisons in the same environment, for example the same container image locally and in CI. Keep one baseline per browser and platform: Playwright puts both in the file name, as in example-test-1-chromium-darwin.png.[1] |
| Animations and transitions | What the diff looks likeOne element caught at another moment: half faded, a few pixels off, a spinner at another angle, a text cursor on or off. | FixStop them before the capture. toHaveScreenshot does it by default: animations: "disabled" and caret: "hide". In other tools, add a stylesheet that turns animations and transitions off.[2] |
| Lazy-loaded and late content | What the diff looks likeEmpty boxes or placeholders where images should be. If the late content has a different height, everything below it is marked as changed. | FixWait for the element you need to be visible, and scroll to content that only loads on scroll. toHaveScreenshot retakes the screenshot until two in a row match, which catches late paints but not content that never started loading.[2] |
| Timestamps and random data | What the diff looks likeA small block of changed text in the same place on every run: a date, "3 minutes ago", an order number, a rotating avatar. | FixUse fixed test data and a fixed clock (page.clock.setFixedTime() in Playwright). Cover what cannot be fixed with mask.[2][5] |
| Anti-aliasing | What the diff looks likeSingle scattered pixels along curved and diagonal edges, rounded corners and text. | FixLeave anti-aliasing detection on (pixelmatch ignores those pixels unless includeAA is true) and keep a small colour threshold instead of a large pixel budget.[2][3] |
| GPU, hardware and headless mode | What the diff looks likeFaint, spread-out differences in shadows, gradients, images and canvas drawings. Nothing has moved. | FixRun on the same kind of machine in the same mode every time. Playwright lists the host OS, hardware, power source and headless mode among the things that change rendering.[1] |
| Scrollbars | What the diff looks likeA changed strip down the right edge. When the scrollbar takes up width, the layout is narrower and text wraps differently. | FixCapture in one browser mode only, or hide scrollbars with a stylesheet applied at capture time (stylePath in Playwright).[1] |
| Cookie banners, ads and chat widgets | What the diff looks likeA large block that is there in some runs and not in others, or the same slot with different content each time. | FixBlock the requests of third parties in the test, hide the elements with a capture stylesheet (the Playwright docs hide every iframe this way) or cover them with mask. BackstopJS has hideSelectors for the same job.[1][6] |
| Viewport size and device scale factor | What the diff looks likeNo useful diff at all: the two images have different sizes, or every element has moved because the layout changed. | FixSet the window size and the scale explicitly. pixelmatch needs both images to have the same dimensions, BackstopJS fails a size change by default (requireSameDimensions), and Playwright keeps one pixel per CSS pixel with scale: "css".[2][3][6] |
| Hover and focus states | What the diff looks likeOne button or link in its hover colour, because the mouse pointer happened to rest on it. | FixMove the pointer away before the capture. The Playwright docs use await page.mouse.move(-1, -1).[1] |
Do not fix flakiness with a bigger number
Visual regression testing tools
Visual regression testing tools differ in three things: what they capture, how they compare, and where the browser runs. The table lists eight of them without ranking, using what each project says about itself.
| Tool | What it is | What it compares | Where it runs |
|---|---|---|---|
Playwright toHaveScreenshotOpen source, part of the Playwright test runner | What it comparesA screenshot of a page or an element against a golden file kept next to the test, using pixelmatch. | Where it runsWherever the tests run. One baseline per browser and platform.[1][2] | |
| Applitools EyesHosted commercial service | What it comparesCheckpoints sent by its SDKs against a baseline, by match level: Exact is pixel to pixel, Strict looks for visible changes, Layout checks the position of elements. | Where it runsSDKs inside your existing tests, comparison on the service.[10] | |
| ArgosOpen source (MIT) platform | What it comparesScreenshots uploaded from CI by its SDKs for Playwright, Cypress, Storybook, Vitest, WebdriverIO and Puppeteer, or by its CLI. | Where it runsScreenshots are taken in your CI. Review happens in a build, with a check on the pull request.[13][14] | |
| BackstopJSOpen source (MIT) | What it comparesScreenshots of the URLs, viewports and selectors listed in a config file, taken with Puppeteer or Playwright and compared with Resemble.js. misMatchThreshold is 0.1 percent by default. | Where it runsYour machine or CI, with a --docker flag for consistent rendering. Its README says the project is looking for a new maintainer.[6] | |
| ChromaticHosted service, from the team behind Storybook | What it comparesSnapshots of Storybook stories and of Playwright and Cypress tests, pixel by pixel against a baseline. | Where it runsCloud browsers: Chrome by default, with Firefox, Safari and Edge available.[7][8] | |
| LokiOpen source (MIT) | What it comparesStorybook stories. The diffing engine is pixelmatch by default, with GraphicsMagick and looks-same as alternatives. | Where it runsChrome in Docker, a local Chrome, AWS Lambda, the iOS simulator or the Android emulator.[12] | |
| PercyHosted service, a BrowserStack product | What it comparesA DOM snapshot captured by its SDK, rendered again across browsers and widths, and compared with the approved baseline. | Where it runsThe snapshot is taken in your tests. Rendering and comparison happen on Percy infrastructure.[9] | |
| reg-suitOpen source (MIT) command line tool | What it comparesAny directory of image files against the previous set, with an HTML report. thresholdRate and thresholdPixel are 0 by default. | Where it runsYour CI. Plugins keep snapshots in S3 or Google Cloud Storage and notify GitHub, GitLab or Slack.[11] |
Prices and plan limits change often and are left out on purpose. Check the site of each project.
What to test, and what to leave out
Visual tests pay off on parts of the interface that stay the same between runs, and cost more than they catch everywhere else. Test what you control, and mask or skip what you do not.
Test
- Design-system components, each in every state: default, hover, disabled, error, loading.
- The pages where a visual bug costs the most: home, pricing, sign-up, checkout.
- Pages rendered with fixed, seeded data, so the content is the same on every run.
- States that are hard to assert in code: empty lists, very long names, right-to-left text.
- A small set of window sizes, for example one phone width and one desktop width.
Leave out, or mask
- Third-party embeds: maps, video players, ads, chat widgets, social feeds.
- Live data: dashboards, charts, counters, clocks, anything that reads "today".
- Pages inside an A/B test, where two visitors rightly see different things.
- A whole long page when one component would do: one changed line marks everything under it.
- Every browser at every size from day one. Each baseline is a file somebody has to review.
How to set up visual regression testing
Setting up visual regression testing is eight steps, and the order matters: make the screenshot repeatable before you write the first assertion, and choose a tolerance last.
Pick a few targets
Start with a handful of components and two or three key pages. Add more once these run without false alarms.
Fix the environment
One browser version on one operating system image, used both to create baselines and to run the comparison in CI.[1]
Fix the window
Set the viewport width, height and scale in the test config, so every screenshot has the same dimensions.
Make the page deterministic
Seed the data, set a fixed clock, block third-party requests and wait for fonts and images before the capture.[5]
Create the baseline
Run the test once. Playwright reports that a snapshot does not exist, writes the screenshot as the golden file and compares against it from the next run on. Commit the file.[1]
Set the tolerance last, and keep it small
Stay on the defaults until you see real noise. Then add a small
maxDiffPixels, not a broad ratio.[1]Review, then approve
Read every failure as a diff. When the change is intended, update the baseline with
npx playwright test --update-snapshotsand review the new image in the pull request.[1]
import { test, expect } from '@playwright/test';
test('pricing page looks the same', async ({ page }) => {
await page.goto('https://example.com/pricing');
await expect(page).toHaveScreenshot('pricing.png', {
fullPage: true,
mask: [page.locator('.last-updated')],
maxDiffPixels: 100,
});
});toHaveScreenshot.[2] The first run writes pricing-chromium-darwin.png on a Mac with Chromium, since the browser and platform are part of the file name.[1]Where a screenshot API fits
A screenshot API fits the cases where the page to check is already deployed and there is no test suite around it: staging next to production before a release, or the same live page every morning. Every capture is rendered by the same browser set-up, which removes the largest cause of false differences, the environment.
CurlShot captures and does not compare. It has no diffing feature: you get two files, and a tool such as pixelmatch or reg-suit compares them. For components and pages inside your own test run, the snapshot assertion of your test runner is the better fit.
The request on this page asks for a fixed window, one image pixel per CSS pixel, a quiet network, stopped animations and no banners, ads or chat bubbles. The response cache is off unless you ask for it, so each request is a fresh render. Read more about the screenshot API, waiting and timing and blocking.
curl "https://curlshot.com/api/v1/screenshot?access_key=YOUR_ACCESS_KEY\
&url=https://example.com\
&viewport_width=1280\
&viewport_height=720\
&device_scale_factor=1\
&wait_until=networkidle\
&reduced_motion=true\
&block_ads=true\
&block_cookie_banners=true\
&block_chats=true\
&time_zone=UTC" \
--output staging.png- viewport_width, viewport_height = 1280, 720
- The same window on every capture, so both images have the same size and layout.
- device_scale_factor = 1
- One image pixel per CSS pixel.
- wait_until = networkidle
- Capture after the page has stopped fetching things.
- reduced_motion = true
- Asks the page to skip animations, and stops CSS animations and transitions at the moment of an image capture.
- block_ads, block_cookie_banners, block_chats = true
- Removes the blocks that come and go between runs. Blocking is best-effort.
- time_zone = UTC
- Dates on the page do not depend on where the request was rendered.
Visual testing glossary
- Baseline
- The approved screenshot that new screenshots are compared with. It shows how the page is supposed to look.
- Golden image
- Another name for the baseline. Playwright calls the stored file a golden file or a snapshot.
- Snapshot
- A stored capture used in a test. In visual testing it is a screenshot. Some services store the DOM and its assets instead and render the image later.
- Diff
- The result of a comparison: the count of pixels that differ, and an image with those pixels painted in a strong colour.
- Threshold
- How much difference is tolerated. It comes in two kinds: how far apart two colours may be before a pixel counts as different, and how many different pixels a test may have.
- Flaky test
- A test that passes on one run and fails on the next without any change to the code under test.
- Anti-aliasing
- The half-shaded pixels a browser draws along the edge of letters and curves to make them look smooth. Their exact shade varies between machines.
Questions people ask
What is visual regression testing?
It is automated testing of how a page looks. A tool takes a screenshot, compares it with a stored screenshot that was approved earlier (the baseline) and reports any difference above a tolerance, so that a change in code cannot alter the layout unnoticed.
How does screenshot comparison work?
Both images are decoded to pixels and compared position by position. A pixel counts as different when its colour is further from the baseline than a threshold, anti-aliased edge pixels are usually ignored, and the test fails when the number or share of different pixels exceeds the allowed budget. The images must have the same dimensions.
Why do visual regression tests fail when nothing changed?
Because the screenshot was rendered differently: another operating system draws fonts differently, an animation was caught mid-way, an image loaded late, a date changed, or a cookie banner appeared. Run baselines and tests in the same environment, stop animations, fix the data and the clock, and mask what still moves.
What are the default thresholds in Playwright?
For toHaveScreenshot the per-pixel threshold defaults to 0.2 on a scale from 0 (strict) to 1 (lax). maxDiffPixels and maxDiffPixelRatio are unset by default and can be given per assertion or in the expect section of the Playwright config. Animations are disabled and the text caret is hidden by default.
What is a good maxDiffPixels value?
As small as your noise allows. Start with none, look at the real diffs, and add a small count such as the 100 used in the Playwright docs. A ratio is riskier on large images: 0.01 of a 1280 x 720 screenshot is 9,216 pixels, enough to hide a missing icon or a moved button.
How do I update a baseline in Playwright?
Run npx playwright test --update-snapshots. Playwright rewrites the stored screenshots with the current rendering. Do it in the same environment that runs the tests, and review the changed image files in the pull request like any other change.
Does CurlShot compare screenshots?
No. CurlShot captures screenshots of public addresses in a consistent browser environment, with a fixed window size, blocking and waiting options. Comparing two captures is done with a separate tool, such as pixelmatch or reg-suit.
Sources
- [1]Playwright, Visual comparisons. Checked .
- [2]Playwright, PageAssertions: toHaveScreenshot. Checked .
- [3]Mapbox on GitHub, pixelmatch README. Checked .
- [4]American Express on GitHub, jest-image-snapshot README. Checked .
- [5]Playwright, Clock. Checked .
- [6]BackstopJS on GitHub, BackstopJS README. Checked .
- [7]Chromatic, Visual tests. Checked .
- [8]Chromatic, Browser support. Checked .
- [9]BrowserStack, Percy SDK and screenshot capture workflow. Checked .
- [10]Applitools, Match levels. Checked .
- [11]reg-viz on GitHub, reg-suit README. Checked .
- [12]Loki, Configuration. Checked .
- [13]Argos on GitHub, Argos repository. Checked .
- [14]Argos, Getting started. Checked .
Read next
- Field guideWhy website screenshots look wrong, and the fixesA field guide to 11 ways a website screenshot goes wrong, from blank lazy images to cookie banners and cut-off pages, with the cause and the fix for each.
- CalculatorScreenshot API vs headless browser: the real costA cost calculator with every assumption editable, the hidden work behind headless Chrome, and an honest checklist for build or buy.
- Cheat sheetViewport sizes cheat sheet: phones to desktopsViewport sizes in CSS pixels, screenshot sizes and device pixel ratios of iPhone, Pixel, Galaxy, iPad and laptop screens, drawn to scale, and the sizes to test.