🔧 Vitest Visual Testing

Make @uiverify/vitest (Vitest browser-mode) captures deterministic so component visual tests stop coming back "changed" without a real change (flaky diffs). Use when setting up or debugging visual tes

About Vitest Visual Testing

`@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.

This large 2,511-word instruction set is purpose-built for testing & qa work in AI coding agents. Paste directly into your CLAUDE.md or AI agent settings.

Key Capabilities

  • fixed counts (stars, followers, downloads) — literal numbers, not a live fetch;
  • a fixed contributor/author list: fixed names and avatar URLs (or inlined avatars), in a fixed
  • a fixed set of tiles/rows in a fixed order (a live "trending" sort reorders every run);
  • fixed timestamps (pair with the clock, step 2).
  • Preferred — honor reduced motion. The capturer emulates `prefers-reduced-motion: reduce`, so make

What This Skill Covers

  • Mental model — component isolation already removed most of the flake
  • The one Vitest-specific trap: capture before the component settled
  • The checklist (only what the tool can't do for you)
  • Testing whole pages
  • Anti-patterns

Use Cases

  • Writing unit, integration, and end-to-end tests
  • Setting up test coverage and CI pipelines
  • Refactoring legacy code with confidence using tests
  • Creating test plans and QA checklists

Example Prompts

Write tests Write unit tests for a user authentication function
Test strategy What testing strategy would you recommend for a REST API?

System Prompt (2,511 words)

Deterministic Vitest captures (browser mode)

Mental model — component isolation already removed most of the flake

@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 (document.fonts.check('16px "bootstrap-icons"') is false
until the CSS is imported, which is the whole "icons render as boxes" bug).

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 one Vitest-specific trap: capture before the component settled

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.

The checklist (only what the tool can't do for you)

1. Freeze the data — the one that actually matters

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:

  • fixed counts (stars, followers, downloads) — literal numbers, not a live fetch;

  • a fixed contributor/author list: fixed names and avatar URLs (or inlined avatars), in a fixed

order;
  • a fixed set of tiles/rows in a fixed order (a live "trending" sort reorders every run);

  • fixed timestamps (pair with the clock, step 2).


Two ways to inject them, both fine:
// (a) pass fixtures 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](https://github.com/uiverify/uiverify/tree/main/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.

2. Freeze the clock

The one thing the capturer deliberately does not do. Any component that reads the clock — a relative
timestamp, a date defaulting to "today", a chart's day axis — drifts every run. Pin it with Vitest's fake
timers before you render:

import { beforeEach, afterEach, vi } from 'vitest';

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2020-01-01T00:00:00Z'));
});
afterEach(() => vi.useRealTimers());

If a component animates on mount and fake timers freeze it half-way, advance to the end
(vi.runAllTimers()) or set the time
after* the render settles.

3. Infinite JS animations

A CSS/WAAPI/finite animation is handled for you (above). What's left is an infinite JS loop with no
final frame — framer-motion pulsing dots, a Lottie loop, an autoplay spinner, or a <canvas> /
requestAnimationFrame loop (which no media query can reach). Two fixes:

  • Preferred — honor reduced motion. The capturer emulates prefers-reduced-motion: reduce, so make
the component respect it (framer-motion: <MotionConfig reducedMotion="user">, or gate the loop with useReducedMotion()). One line, and it's good app behavior anyway.
  • Test-only switch for Motion (framer-motion). MotionGlobalConfig.skipAnimations = true (from
motion/react or framer-motion) in your setup file makes every Motion animation jump to its end state, including height and scroll animations no CSS can stop. No app change needed.
  • Escape hatch — detect the capture and render the end state (for a <canvas> rAF loop:
if (isUIVerify()) drawOneStaticFrame(); else startRaf(); in the effect). The canonical helper reads two signals — a UIVerify navigator.userAgent marker and a window.__UI_VERIFY__ global:
  export const isUIVerify = () =>
    (typeof navigator !== 'undefined' && navigator.userAgent.includes('UIVerify')) ||
    (typeof window !== 'undefined' && '__UI_VERIFY__' in window);
  <RadarChart isAnimationActive={!isUIVerify()} />
In Vitest browser mode the load-bearing signal is the window.__UI_VERIFY__ global (the browser provider owns the context, so the SDK sets the global, not the UA marker) — use the helper as-is; the global is what fires here.

4. Non-Math.random randomness

Math.random is seeded for you, but crypto.randomUUID(), a uuid library, or faker are not. Use
fixed fixtures for anything that reaches the DOM (an id, a faker name), or set a fixed faker seed.

5. Dynamic layout

A JS-measured layout that reflows or reorders on its own (packing driven by measured size, a shuffled
list) can vary run-to-run even with identical content. Force a deterministic variant (a fixed
order/size), or mask the region (step 6).

One measured-layout case the SDK already handles: a component that measures text width on mount (a
sliding tab or switch highlight) can bake a 1px-shifted position if the font wasn't ready at that first
layout. The SDK preloads fonts before each test so the first measurement uses real metrics. If your CSS
or a font registers late (an unusual setup) and you still see a sub-pixel shift, call preloadFonts()
from @uiverify/vitest before render() to force it - settling after render can't undo a
measurement already taken.

6. Mask what you can't pin

Last resort for content that is supposed to differ and can't be pinned at the source (a third-party
embed, a map, an ad slot, a live feed): add data-uiverify-ignore to the element. UI Verify paints
its box solid black on every capture, so its content never reaches the diff. The box's size and position
still diff (give it a fixed size), and nothing inside it is checked, so mask only what you can't pin and
fix what you own. [Docs](https://uiverify.ai/docs/ignore-regions).

<aside data-uiverify-ignore style={{ width: 320, height: 256 }}><AdSlot /></aside>

Testing whole pages

Browser mode can render a whole page, not just a component: render the page's own component inside the
same layouts and providers your app wraps it in (router, theme, data client), so the capture shows the real
header, sidebar and layout. This works in any framework; you need no running app, dev server or database.
Replace only the boundary, once, in the setup file, and keep everything inside it real:

  • data: answer the page's API calls (REST, GraphQL, tRPC) from per-test fixtures with a stubbed
fetch or a mock of your data module, failing loudly when a test hits a call it has no fixture for;
  • the request: the current path, search params, cookies and signed-in user, read from a small
per-test object by your router and auth doubles;
  • what only runs on a server: database clients and other server-only modules, and any framework API
that reads the incoming request.

Then follow the rest of this skill as for components: fixtures for every piece of data, a frozen clock
(vi.useFakeTimers({ toFake: ['Date'] })), the app's global CSS and fonts in the setup file, and the shared
settle helper above. Capture each page in the sizes and themes your users see (for example desktop and
mobile, light and dark), and skip a variant only where it looks identical to another one.

Next.js App Router. Import the route's page.tsx and wrap it in its layout.tsx files. React does not
officially support async components on the client, but React 19 still renders an async server component
there when it sits inside <Suspense>, logging a console error per async component (filter those in the
setup file). Because the client re-runs the component on every retry, keep its awaited work settling: return
the same promise or cached data on each run rather than starting a new request. Replace server-only, next/headers
(cookies(), headers()), next/cache, next/navigation and next/link, plus the data functions the
route files call on the server, and load the fonts next/font provides in the app.

Anti-patterns

  • A test that fetches live data → mock it (vi.mock / MSW).
  • takeSnapshot() (or letting the test end) before the component committed or rendered its data →
archives a half-rendered or blank frame; await render(...) (it is async), then await the settled state.
  • Disabling CSS animations / seeding Math.random / waiting on fonts by hand → wasted effort; the
capturer already does all three. Spend the effort on the clock, settling, and infinite loops.
  • Unseeded crypto/uuid/faker or a bare Date.now() → fixtures + freeze the clock.
  • Reaching for scroll-settle / A-B stubbing in a component test → wrong path; that's a page concern.
Whole pages rendered in browser mode use the shared settle helper above.

Frequently Asked Questions

What is Vitest Visual Testing?

Vitest Visual Testing is a free testing & qa skill for AI coding agents. Make @uiverify/vitest (Vitest browser-mode) captures deterministic so component visual tests stop coming back "changed" without a real change (flaky diffs). Use when setting up or debugging visual tes. It provides a specialized system prompt that configures your agent with testing & qa expertise.

How do I use Vitest Visual Testing with Claude Code?

Copy the system prompt below and paste it into your CLAUDE.md file or Claude Code's custom instructions to activate Vitest Visual Testing.

Which AI coding agents work with Vitest Visual Testing?

Vitest Visual Testing is compatible with Claude Code, Cursor, GitHub Copilot, Windsurf, OpenClaw, Cline, and any AI agent that supports custom system prompts or .cursorrules files.

Is Vitest Visual Testing free to use?

Yes, Vitest Visual Testing is completely free and open source. The full source is available on GitHub at https://github.com/uiverify/uiverify/blob/main/packages/skills/skills/vitest-visual-testing/SKILL.md. You only need a subscription to the AI agent you use it with.

Related Skills

Get the best new skills
in your inbox

Weekly roundup of top Claude Code skills, MCP servers, and AI coding tips.