Make charts and canvas UIs testable
Visual testing charts and canvas UIs is hard because they redraw constantly and render live data. Freeze the data, the clock, and the fonts, and the bugs we hit doing it.
Charts and canvas UIs are the hardest visual surface there is, because they redraw many times a second, animate in on mount, and render live or non-deterministic data. A naive screenshot catches a different frame every run, so the diff flakes even when nothing changed. Dataviz is also the one surface even change-tolerant teams refuse to ship broken, and most of them still verify it by hand. I have chased three separate chart flakes in our own captures, so this post is part how-to and part confession.
Why do teams leave charts out of their visual tests?
Picture the hard case: a data viewer for scientific instruments, a canvas chart library wrapped in React, custom zoom math on top, a graph that updates many times a second, zoomable from seconds out to a year. The rest of that app tests fine with Playwright. Only the plots get excluded, because writing those tests takes longer than checking the plots by hand before every release, so that is what happens. The data cannot be mocked past a point either; a realistic fixture would be megabytes. And even orgs that tolerate UI regressions almost everywhere do not tolerate them in charts, where any regression is a serious problem.
Both are the same shape: the surface people trust least to automate is the one they are least willing to ship wrong. That is a bad combination to leave to a human glance.
Why does a chart vary between two identical runs?
The entry animation is mid-tween when you capture. The data is a live feed, so the bars are a different height. A tooltip follows a cursor that moved. A time axis is anchored to now, so the labels shift every minute. None of that is a real change, but a pixel diff counts all of it, and you learn to ignore the tool. The fix is to remove the variation before you snapshot, not to loosen the threshold until it stops complaining.
Three chart flakes I have actually debugged
- The tick label that moved two pixels. One Recharts x-axis label shifted by one or two pixels between runs while the line and grid were byte-identical. It took a week to name. Recharts measures text once and caches the width module-wide, keyed by text and font but not by whether the web font had finished loading, so on a cold render it caches fallback-font metrics. The fix was in our capturer, loading fonts eagerly before first paint: 183 correct out of 200 runs became 200 out of 200. A first attempt to fix it on the app side, a blocking font loader, hung on a mocked font and was reverted.
- Twelve chart stories failing silently. In our render container
navigator.languagewasen-US@posix, which is not a valid language tag. uPlot passed it toIntland threw before drawing a single pixel, so every chart story rendered blank behind a green build. Nobody would have found that from a customer report. The fix was pinning the container's locale and timezone. - The chart we still do not capture on the Playwright path. Our own project overview has a Recharts area chart that sizes its SVG from a runtime measurement of its container, and that measurement varies sub-pixel between renders. We chose not to mask it in the product component, so that page is deliberately excluded from the Playwright suite. The same chart, rendered as a story from a fixture at a fixed size, captures cleanly.

What the tool neutralizes, and what is yours
| Source of variation | Handled by UI Verify | Yours to fix |
|---|---|---|
| Clock and now-anchored axes | Frozen | |
| Entry and tween animations | Frozen via reduced motion | |
| Math.random jitter | Seeded | |
| Web font loading | Loaded before first paint | |
| Container size | Render at a fixed width and height | |
| The data series itself | Feed a fixed fixture |
UI Verify pins the clock, freezes animations through prefers-reduced-motion, seeds Math.random, inlines fonts, and runs the network sealed, then auto-ignores the residual flake; see deterministic captures and flake detection. What it cannot do is invent a stable data series or decide how big your chart should be. If your chart reads a live feed, the tool has no way to know what the numbers should be.
The recipe
new Chart(ctx, {
type: "line",
data: fixtureSeries, // never a live feed
options: { animation: false, responsive: false },
});
// and render the canvas at an explicit width and height- Feed a fixed data series from a fixture, never a live feed. If the real data is megabytes, capture a representative slice; you are testing the drawing, not the dataset.
- Turn the chart library's own animation flag off, so the first painted frame is also the final one.
- Render at a fixed width and height, not a fluid box that reflows to its container.
- Anchor any time axis to a fixed date, not now.
- For a canvas, wait for the draw to settle before capturing; a canvas paints asynchronously and an early screenshot catches a half-drawn frame.
Once the data, the clock, and the size are pinned, a chart becomes as testable as a button. The same holds for a raw canvas game board, a seeded network graph, or a WebGL scene: fix the inputs, wait for the frame, capture. The general version of this discipline is in fix flaky Playwright screenshot tests. The payoff is that dataviz stops being the thing you squint at by hand on every release: a deterministic chart capture diffs only when the chart really changed, and the AI judge reads the PR to decide whether that change was meant.
Stop eyeballing your charts
Freeze the data and the clock, and let deterministic captures turn a flaky canvas into a diff you can trust. Start free with 10,000 snapshots a month.
Start for freeNo credit card required.
Set up visual testing
From no visual tests to a green check on every pull request.