by igrlk
Make Vitest browser-mode visual tests deterministic so component and whole-page captures stop coming back changed without a real change. Covers the Vitest-specific trap of capturing before the component settles, then the data, the clock, animations, randomness and dynamic layout. Official skill from UI Verify, visual regression testing for coding agents (uiverify.ai).
npx @qaskills/cli add vitest-browser-mode-visual-regression-testing-ui-verifyAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
@uiverify/vitest archives each browser-mode test's final DOM + every resource it uses; UI
Verify re-renders and pixel-diffs that archive server-side. Because a browser-mode component test renders
an isolated component (no page scroll, no A/B / analytics / chat / consent scripts, no
lazy-load-on-scroll races), you get the same head start Storybook gives you: the determinism work here is
narrow. If you reach for scroll-settling or third-party stubbing in a component test, you're fighting
a problem component isolation already removed. Whole pages rendered in browser mode bring some of it back;
see "Testing whole pages" below.
Whatever the component is at the end of the test (or at your
takeSnapshot()call) is baked into the archive forever. Your job is to drive it to one canonical state before capture.
Integration is one plugin (no per-test code):
// vitest.config.ts
import { playwright } from '@vitest/browser-playwright';
import { uiverifyPlugin } from '@uiverify/vitest/plugin';
export default defineConfig({
plugins: [uiverifyPlugin()],
test: { browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium' }] } },
});
Every browser-mode test then archives its final DOM automatically; a test that calls
takeSnapshot('name') archives those named snapshots instead.
Load your app's global CSS + fonts once, in a setup file. An isolated browser-mode test renders a
single component — it does not import your app's entry/layout, so your global stylesheet and any
icon-font CSS (bootstrap-icons, an icon pack) are never loaded, and the component captures with
fallback fonts / tofu glyphs. This is a setup gap, not a per-test one: load them once in a
setupFiles file (it runs in the browser, in the same document your components render into) so every
test inherits them with no duplication:
// vitest.setup.ts ← referenced from test.setupFiles
import 'bootstrap-icons/font/bootstrap-icons.css'; // icon @font-face + .bi-* classes
import './src/styles/globals.css'; // your tokens + base font
// vitest.config.ts
test: { setupFiles: ['./vitest.setup.ts'], browser: { /* … */ } }
Import fonts from an npm package or a same-origin asset (not a CDN <link>), so Vite serves the bytes
and the archive stays self-contained. A font CDN or a third-party image (avatar generators, favicon
services) is archived too, but it is downloaded again at every capture, so an outage or a rate limit
becomes a missing font or image in that build. The test output then shows a [uiverify] … could not archive … warning naming it. Serve fixtures a local copy or a data: URI to keep the archive independent
of those services.
The capturer then waits for these fonts like any other — but it can only wait for fonts the test actually
loads; it can't declare one you never imported, which is the whole "icons render as boxes" bug. To confirm
the font is there, (await document.fonts.load('16px "bootstrap-icons"')).length must be above 0: it is 0
when no @font-face declares the family. Don't use document.fonts.check() for this — it returns true for
a family that was never declared, so it passes exactly when the CSS is missing.
Tailwind v4 in a monorepo. Tailwind generates only the classes it finds in the files it scans, starting
from where Vitest runs. When the visual tests run from another package than the app, add @source lines for
the app's folders (app/, components/, lib/) to the CSS your setup imports; otherwise classes used only
in those folders are missing and the capture differs from the real app with no error.
UI Verify's capturer neutralizes these automatically — do NOT hand-fix them: CSS animations &
transitions (killed at render) and the Web Animations API (disabled); prefers-reduced-motion: reduce
(emulated); Math.random (seeded before your app code runs); web fonts, <img> and CSS
background images (::before/::after included) (waited for — the fonts your app actually loads, see
the setup file above; loading="lazy" images below the viewport are switched to eager so they load too); finite JS animations
(captured at their settled final frame). And unlike a real
page (playwright-visual-testing), a browser-mode test has no SSR — no server-rendered random pick
to reconcile. So the checklist below is only the remainder — what lives inside your app.
The headline: freeze the data. Once the list above is off the table, the one thing left that floods a component suite with false "changes" is live/dynamic data — star counts, follower counts, contributor lists, tiles, timestamps. Feed every component static fixtures and that entire class disappears at the source: static data can't churn run-to-run, so there is nothing to diff. This is the single highest-value determinism step here — do it first (step 1), and most components need nothing else.
The auto-snapshot fires at the end of every test (a failed one too, so a broken component shows up as a
visual change rather than vanishing from the build), and takeSnapshot() fires the moment you call it. If the component is still resolving a promise, running a transition, or hasn't rendered its
data yet, you archive a half-rendered frame. Drive it to its final state first — await your render
helper, wait for the content to appear, then let the test end (or call takeSnapshot()):
import { render } from 'vitest-browser-react'; // or your framework's browser render helper
import { takeSnapshot } from '@uiverify/vitest';
import { expect, test } from 'vitest';
test('user card', async () => {
const screen = await render(<UserCard id="u_1" />); // render() is async - await it, or `screen` is a Promise
// expect.element retries until the content shows; a bare .query() checks once and does not wait.
await expect.element(screen.getByText('Ada Lovelace')).toBeVisible();
await takeSnapshot();
});
This is the analog of a Playwright test's navigation + assertions: your render + waits are the
determinism surface.
Wait for everything the component loads, not only the part you assert on: a sidebar, a balance or an
avatar that loads on its own request can still be loading when your assertion passes. With TanStack Query,
wait until no query or mutation is in flight (queryClient.isFetching() === 0 && queryClient.isMutating() === 0); with other clients, until your fetch mock has answered every request. If a JS-driven layout keeps moving
after the content shows (a chat that scrolls itself to the bottom, a measured highlight), also wait until
element positions and scroll offsets stop changing for a few frames. Put these waits in one helper the tests
share. On Vitest 4.1 and later the automatic snapshot is taken as soon as the test body finishes, before any
afterEach hook (except for concurrent tests and with sequence.hooks: "parallel"), so a wait in an afterEach comes too late: call the helper from the test (or have that hook
call takeSnapshot() itself). If you turn the automatic snapshot off (disableAutoSnapshot()) and call
takeSnapshot() at the end of that helper, every capture is guaranteed to come after the wait.
A component fed live/dynamic data is flaky by construction: the stars, followers, contributor list, tile order, and timestamps move between runs, so the diff lights up with no code change. Give every component static fixtures and the whole class is gone. Never let a test hit a real backend.
Concrete — feed fixed, ordered, complete fixtures:
Two ways to inject them, both fine:
// (a) hand the fixtures in as props — the simplest, when the component takes its data as props
await render(<LibraryTile name="ktor" stars={12873} platforms={['jvm', 'js', 'native']} />);
// (b) mock the data module the component imports — when it fetches internally
vi.mock('../api/library', () => ({ getLibrary: () => fixtures.ktor }));
If a page is a server component that fetches, render its client presentational subtree with fixture
props instead of the fetching wrapper — a browser-mode test has no server to run the fetch anyway. MSW in
a setup file also works for fetch-based components; the rule is only no real request.
Copy the example — it's the reference implementation. The *.visual.test.tsx files in
examples/vitest are real @uiverify/vitest tests
with this exact shape: render the component → await expect.element(...).toBeVisible() → await takeSnapshot(), all data static.
One canvas per component, not N stories. Render every variant × state of a component (a Button's sizes/states, every tile kind) in a single grid and take one snapshot — cheaper (one screenshot), and you eyeball the whole component's surface at once. Keep each page/component in its own test file so unchanged ones are skipped, and add a path filter so the visual job only runs on UI PRs — both keep the suite cheap at scale.
This is the first part of the skill. The complete, current version: https://github.com/uiverify/uiverify/blob/main/packages/skills/skills/vitest-visual-testing/SKILL.md
- name: Install QA Skills
run: npx @qaskills/cli add vitest-browser-mode-visual-regression-testing-ui-verify4 of 29 agents supported
Build AI agents that write, run, and fix tests. Playwright, LLM evals, and CI in one live cohort.