How to Set Up Visual Regression Testing with Vitest
Set up stable Vitest screenshot tests with Browser Mode, Playwright, committed baselines, CI checks, and practical fixes for flaky diffs.
Vitest visual regression testing runs browser-based tests, captures screenshots, and compares them with committed reference images. In Vitest Browser Mode, the built-in toMatchScreenshot() assertion gives you a repeatable way to detect unintended visual changes.
A dependable setup has four parts: a Playwright-backed browser project, visual tests separated from unit tests, fixed rendering conditions, and a review process for baseline changes. The examples below use TypeScript and Chromium.
1. Install Vitest Browser Mode and Playwright
Use a current Vitest release and install the browser provider:
npm install -D vitest @vitest/browser-playwright playwright
npx playwright install chromium
Vitest also provides an initializer:
npx vitest init browser
The initializer can create a starting Browser Mode configuration. The explicit configuration below is useful when you need separate unit and visual projects. Vitest documents Browser Mode and provider setup in its Browser Mode guide and visual regression guide.
2. Separate unit and visual projects
Keep visual tests in files such as *.vrt.test.ts. This prevents screenshot failures from hiding ordinary behavioral test failures and lets CI run each suite independently.
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
export default defineConfig({
test: {
projects: [
{
extends: true,
test: {
name: 'unit',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
exclude: ['**/*.vrt.test.ts', '**/*.vrt.test.tsx'],
},
},
{
extends: true,
test: {
name: 'vrt',
include: ['src/**/*.vrt.test.ts', 'src/**/*.vrt.test.tsx'],
browser: {
enabled: true,
provider: playwright(),
instances: [
{
browser: 'chromium',
viewport: { width: 1280, height: 720 },
},
],
},
},
},
],
},
})
The 1280×720 viewport is an example. Choose dimensions that represent your supported layout and keep them fixed when creating and comparing references. Pin the browser, operating system, fonts, dependency lockfile, and CI image whenever possible.
3. Write a visual regression test
Render the component using the same helper your application uses, assert important behavior separately, then capture the intended visual boundary.
import { expect, test } from 'vitest'
import { page } from 'vitest/browser'
// Replace this with your framework's normal render helper.
import { render } from './test-utils'
import { SaveForm } from './SaveForm'
test('primary save button keeps its visual design', async () => {
await render(<SaveForm />)
const button = page.getByRole('button', { name: 'Save' })
await expect(button).toBeVisible()
await expect(button).toMatchScreenshot('primary-save-button')
})
A screenshot assertion does not prove that a control works. Keep interaction and state assertions alongside the screenshot test:
test('save form submits and matches its resting state', async () => {
await render(<SaveForm />)
const button = page.getByRole('button', { name: 'Save' })
await expect(button).toMatchScreenshot('save-form-resting')
await button.click()
await expect(page.getByText('Saved')).toBeVisible()
})
4. Create and commit the first baseline
Run the visual project:
npx vitest --project vrt
The first run creates a reference because no prior image exists. Inspect every generated image before committing it. Vitest stores references in __screenshots__ folders next to the test. Commit those files with the test and component code.
git add src/**/__screenshots__ src/**/*.vrt.test.ts
git commit -m "Add visual regression baseline"
On later runs, Vitest captures the page again and compares it with the committed reference. A mismatch should produce expected, actual, and (when dimensions permit) diff artifacts. Red pixels represent differences; anti-aliasing can also appear in the diff depending on comparator settings.
5. Run unit and visual suites separately
Add explicit scripts so developers and CI can run one suite without confusing its failures with the other:
{
"scripts": {
"test:unit": "vitest --project unit",
"test:vrt": "vitest --project vrt",
"test": "npm run test:unit && npm run test:vrt"
}
}
In CI, install the exact browser revision and run the same project:
npm ci
npx playwright install --with-deps chromium
npm run test:unit
npm run test:vrt
Use the same operating system and browser image used to generate your references. Differences in fonts, GPU behavior, screen scaling, browser versions, and headed versus headless execution can create pixels that have nothing to do with your code.
6. Update baselines safely
When a UI change is intentional, update references explicitly:
npx vitest --project vrt --update
Review every changed image and its diff, then commit the approved references with the implementation change. Never update baselines merely to make a failing command pass. Remove stale reference files when tests are renamed or deleted; Vitest does not automatically clean every obsolete screenshot.
7. Make captures stable
Disable animation and transitions
Animations can produce different frames on every capture. The Playwright-backed assertion disables animations by default, but an explicit test stylesheet is useful for application-level transitions:
/* vrt-stability.css */
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
Load this stylesheet from your visual-test setup file. Avoid relying on arbitrary sleeps when a deterministic readiness signal is available.
Control dynamic data
- Mock timestamps, random IDs, user names, prices, and feature flags.
- Seed random generators where randomness is part of the UI.
- Use fixed API fixtures instead of live responses.
- Mask a genuinely changing region with screenshot options when using the Playwright provider.
Vitest waits for stable screenshots by capturing repeatedly until two consecutive captures match or the timeout is reached. An endless animation, rotating carousel, live clock, or streaming update can therefore time out.
Choose the right capture boundary
Capture a component when the regression boundary is a component. Whole-page screenshots include navigation, ads, data, and unrelated layout changes, which increases review noise. Use a page capture when page composition itself is what you need to protect.
8. Configure comparison tolerance deliberately
Small rendering differences can be handled with comparator settings, but tolerance should follow reviewed failures rather than copied sample values. A per-pixel threshold changes how different a pixel may be; allowedMismatchedPixelRatio limits the percentage of pixels that may differ.
test('card uses the approved visual tolerance', async () => {
await render(<ProductCard />)
await expect(page.getByTestId('product-card')).toMatchScreenshot('product-card', {
comparatorOptions: {
threshold: 0.1,
allowedMismatchedPixelRatio: 0.002,
},
})
})
Document why a tolerance exists and keep it as narrow as your application allows. A broad ratio can hide a real layout regression.
9. Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
| Provider package cannot be loaded | @vitest/browser-playwright is missing or versions are incompatible. |
Install the provider and Playwright together, regenerate the lockfile, and use compatible Vitest versions. |
| Chromium executable not found | The browser binary was not installed in the local or CI environment. | Run npx playwright install chromium; in Linux CI use --with-deps. |
| Every pixel differs in CI | Different browser, OS, fonts, viewport, scale factor, or headed/headless mode. | Pin the CI image and browser, install identical fonts, and set an explicit viewport. |
| Screenshot never stabilizes | An animation, timer, carousel, network stream, or changing data keeps moving. | Disable motion, mock data, wait for a deterministic selector, or mask the changing region. |
| Diff is noisy around text | Font files or font rendering differ. | Install and pin the same fonts and browser image; do not immediately increase tolerance. |
| No diff image appears | Expected and actual image dimensions differ. | Compare viewport and device scale settings, then inspect both images directly. |
| Test passes but UI is wrong | The baseline was updated without review. | Restore the previous reference and review the actual and diff images before updating. |
| Old screenshots remain after cleanup | References for renamed or deleted tests are not automatically removed. | Delete stale files from the corresponding __screenshots__ directory. |
10. Performance, reliability, and cost
- Performance: Browser startup is usually the expensive part. Keep one project invocation per CI job, reuse the installed browser cache, and avoid capturing large pages when a component capture covers the requirement.
- Reliability: A reproducible environment matters more than a permissive threshold. Pin lockfiles, browser versions, fonts, viewport, timezone, locale, and test data.
- Parallelism: Run independent visual tests in parallel only after confirming that shared state, ports, fixtures, and snapshots are isolated.
- Review cost: Group related UI changes with their baseline updates so reviewers can connect each changed pixel to a code change.
- Storage: Reference images belong in version control; large suites increase repository size, so keep captures focused and remove stale references.
Or skip the browser setup
If you need screenshots for pages or regression fixtures without maintaining a browser runner, ScreenshotNeo provides a website screenshot API and MCP server. It accepts one GET request and returns PNG, JPEG, WebP, or PDF. Before capture it accepts cookie and consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; the response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server exposes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for all options, including full-page capture, CSS selectors, device presets, custom CSS and JavaScript, waits, request blocking, headers, cookies, geolocation, caching, signed links, async jobs, bulk capture, and usage reporting.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Free usage includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 screenshots, and every feature is available on every plan. Create a free ScreenshotNeo account.
FAQ
Does Vitest visual regression testing require Storybook?
No. You can render components with your application’s normal test helpers and use Browser Mode directly.
Should references be committed?
Yes. Commit reviewed references so local runs and CI compare against the same approved images.
Can I use WebdriverIO instead of Playwright?
Yes. Vitest documents Playwright and WebdriverIO providers. Headless execution requires one of those providers; the preview provider is intended for applicable preview workflows.
How often should baselines be regenerated?
Only when a visual change is intentional or the controlled rendering environment changes. Review the resulting images before committing.
What is the best tolerance value?
There is no universal value. Start strict, inspect real failures, and document the smallest tolerance that accommodates known rendering variation without hiding defects.


