Fix flaky visual tests
A visual test should only diff when the UI really changed. Every flaky diff traces to run-to-run variation you can eliminate at capture time.
A flaky diff comes back changed when nothing actually changed. The screenshot is not lying - it faithfully caught something that moves between runs, like the clock or an animation frame. Remove that source and the flake goes with it.
Common causes of flaky visual tests
- The clock - relative timestamps like "2 hours ago", or a date rendered from
Date.now(). - Animations - an infinite CSS or JS animation caught mid-frame at a different point each run.
- Live data - a component that fetches a real API renders different content every time.
- Randomness -
Math.random, generated ids, shuffled orders. - Third-party widgets - maps, embeds, ad slots that load their own moving content.
- Web fonts - text captured before the font swaps in reflows on the next run.
Fix them at record time
- Freeze the clock to a fixed instant so relative-time labels are identical every run.
- Disable animations with
animation: noneunderprefers-reduced-motion, which UI Verify emulates at capture - a near-zeroanimation-duration(oranimation-iteration-count: 1) leaves an infinite animation running and can still flake under render load. - For a JavaScript animation no CSS media query can reach - a charting library's animate-on-mount prop, a
<canvas>loop - branch on `isUIVerify()` to render the final frame only while capturing (isAnimationActive={!isUIVerify()}), including at SSR/build time via aUI_VERIFYenv var. - Feed components fixture data instead of a live fetch.
- Seed or stub any randomness to a fixed value - and when the value is chosen during server rendering, like a random image picked in a React component, seeding the browser RNG cannot reach it, so branch on `isUIVerify()` instead.
- Stub third-party embeds behind a static placeholder.
- Wait for fonts to load before the screenshot is taken.
- Wait for the loaded content before the screenshot, not a loading skeleton or spinner - a capture taken mid-fetch diffs against the loaded baseline.
Wait for the loaded state, not a spinner
A test that navigates and then asserts a URL can take its screenshot while the page is still fetching, so the capture is a spinner or a skeleton that diffs against the loaded baseline. Wait for the real content to appear before the test ends - a data row, not just its container. One trap on a page that swaps content in: a generic selector can match stale content still in the DOM mid-transition and resolve too early, so prefer waiting for network-idle or for an element that only exists once the new content has rendered.
Storybook vs real pages
For Storybook, control the variation inside the story - fixed props, frozen time, fixture data. For real pages driven by Playwright or Vitest, archive-replay bakes the page in at record time, so a live API or a feature flag cannot shift it on replay - but you still freeze the clock and stub moving widgets before you record. Animations are the exception to baking: the archived CSS re-runs on replay, so the same prefers-reduced-motion freeze applies to archives too, not just Storybook.
Visual testing for agents
UI Verify captures your UI on every pull request and an AI judge tells an intended change from a real regression. See how it works.
Get started