Docs / Testing and review
Vitest browser mode screenshots
Vitest's toMatchScreenshot compares a component with a reference screenshot and fails the test when they differ. Screenery publishes those references, so each component image you approved has a URL you can embed.
Your test stays the gate
Visual regression testing in Vitest needs browser mode, which became stable in Vitest 4.0. You assert with toMatchScreenshot. When a reference is missing, Vitest writes one and fails the test so you review it. After that it compares each new screenshot with the reference.
toMatchScreenshot decides whether the pixels are right. Screenery decides where they are served.
A reference only changes when someone updates it on purpose and commits it. So when you push the references from your default branch, @latest shows exactly what your tests accept. You do not need Screenery's diff gate as well. Vitest is already doing that job.
Where Vitest keeps references
From the Vitest visual regression guide and its config reference for Vitest 5.0, checked October 2026:
- References go in a __screenshots__ folder next to each test file, one subfolder per test file.
- A reference is named {name}-{browser}-{platform}.png, for example primary-chromium-linux.png.
- The name is the argument you pass to toMatchScreenshot. Without one, Vitest builds it from the test's title.
- Failed comparisons write the actual and diff images somewhere else, under Vitest's attachments folder, so they never mix with references.
src/components/__screenshots__/Button.test.tsx/primary-chromium-linux.png
One folder for every reference
You could push src, but it holds fixtures, icons and other images that are not screenshots, and Screenery would publish all of them. Instead, collect the references in one folder with resolveScreenshotPath:
// vitest.config.ts
import path from 'node:path';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
expect: {
toMatchScreenshot: {
// screenshots/button/primary-chromium-linux.png
resolveScreenshotPath: ({ root, testFileName, arg, browserName, platform, ext }) =>
path.join(
root,
'screenshots',
testFileName.replace(/\.(test|spec)\.[cm]?[jt]sx?$/, '').toLowerCase(),
`${arg}-${browserName}-${platform}${ext}`,
),
},
},
},
},
});
Button.test.tsx becomes the folder button. Lowercasing the file name here is your choice, made once in config. Screenery itself never rewrites a name. Two test files with the same name in different folders would now share one folder. If you have those, add testFileDirectory to the path.
Moving existing references? Run vitest --update once with the new config to write them at the new paths, then delete the old __screenshots__ folders.
Names that push
Screenery names each screenshot after its path inside the folder you push, minus the extension. Each part must be lowercase letters, digits, ., _ or -. A name that breaks the rule fails the push, and nothing is published.
Always pass a lowercase name. The name Vitest builds from a test title keeps its capitals. When we tried it, test('renders the Secondary button') produced renders-the-Secondary-button-1-chromium-win32.png, and the push refused it. Name the screenshot yourself:
import { expect, test } from 'vitest';
import { page } from 'vitest/browser';
test('primary button', async () => {
// … render the component with your framework's helper …
await expect(page.getByRole('button', { name: 'Save' })).toMatchScreenshot('primary');
});
screenshots/button/primary-chromium-linux.png → button/primary-chromium-linux https://cdn.screenery.dev/acme/dashboard/button/primary-chromium-linux@latest.png
- The browser and platform stay in the name. Vitest keeps a reference per browser and operating system, because fonts and rendering differ. Create the references on the same OS as CI, for example in a Linux container, and commit only those. References from a Mac would publish as separate -darwin names.
- Treat the name like a route. Renaming a test file or a screenshot renames its URL, and the old @latest URL answers 404 after the next default-branch push. See URLs and asset names.
- Check before you push. npx screenery push ./screenshots --project acme/dashboard --dry-run --commit-timestamp 2026-10-01T00:00:00Z lists every name and contacts nobody.
The workflow
Run Vitest, then push the reference folder. The permissions are the same as in the minimal workflow:
name: screenshots
on:
push:
branches: [main]
pull_request:
jobs:
screenshots:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
# … install dependencies …
- run: npx playwright install --with-deps chromium
- run: npx vitest run
- uses: screenery/push@v1
with:
project: acme/dashboard
path: screenshots
If a screenshot differs from its reference, the Vitest step fails and the push never runs. On main the push moves @latest. On a pull request that updates references, @pr-12 and the preview comment show the new ones. Every input is on The GitHub Action.
This is the same pattern as Playwright's toHaveScreenshot goldens. See Playwright goldens and Argos folders.
Other Vitest tools
Several hosted visual testing tools support Vitest browser mode. Two of them, checked October 2026:
- Chromatic added it on 2 September 2026. Its package, @chromatic-com/vitest, needs Vitest 4.0 or later. It saves each test's DOM, styles and assets, and Chromatic renders and compares the snapshots in its cloud, with a review app on top.
- Argos's package, @argos-ci/vitest, adds an argosScreenshot function for browser mode with the Playwright provider. Your team reviews changes in the Argos web app.
Both have a review step that Screenery does not have. Screenery gives you the published image, a URL that a README or design-system docs page can embed. It moves when your default branch does.