Visual regression testing
Capture deterministic screenshots for diffing, and keep false positives out of your pipeline.
Visual diffing only works if two captures of an unchanged page are byte-comparable. Most of the effort is not in taking the screenshot — it is in removing everything that changes between two runs.
A baseline capture
curl -G https://ssnap.cc/api/v1/screenshot \
-H "Authorization: Bearer $SSNAP_API_KEY" \
--data-urlencode "url=https://staging.example.com/pricing" \
--data-urlencode "width=1280" \
--data-urlencode "height=800" \
--data-urlencode "format=png" \
--data-urlencode "network_idle=true" \
--data-urlencode "delay=500" \
--output baseline/pricing-1280.pngEvery one of those parameters is doing determinism work.
Pin the viewport
Use an explicit width and height rather than a device preset, and pass both — a lone
width is ignored. A pinned viewport means a CSS breakpoint change shows up as a diff
instead of silently reflowing the whole image.
Run the same page at two or three widths and treat each as its own baseline:
1280×800 desktop
768×1024 tablet
390×844 mobileDevice presets are useful when you want a specific user agent as well — see Device emulation and viewports.
Use PNG
png is lossless. JPEG and WebP are lossy, and their encoders can produce different
bytes for identical input, which is a diff your tool will happily report. quality is
ignored for PNG, so there is nothing to pin.
Kill the non-determinism in the page
This is the part no capture parameter can solve for you. Anything that differs between two runs will diff:
- Clocks, relative timestamps, "3 minutes ago".
- Randomised ordering, A/B buckets, rotating testimonials.
- Live counters, stock levels, exchange rates.
- Animations mid-flight, carousels, skeleton loaders.
- Third-party embeds and ads.
Standard fixes: a ?visual-test=1 flag your app reads to freeze the clock and disable
animations, seeded fixture data, and stubbed third-party content in the environment you
capture.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
}Wait properly, not longer
network_idle=true
delay=500network_idle waits for network activity to settle, then delay adds a fixed pause.
The pause is what covers CSS transitions and font swaps, which produce no network
traffic at all.
Do not reach for a huge delay as insurance. The overall render timeout is 30 seconds
and a long delay eats into it — a 25-second delay on a slow page times out with
capture_failed.
Full page or element
full_page=true captures the whole scroll height, which is the honest test but also the
most fragile: one extra row anywhere shifts everything below it, so a one-line change
diffs the entire image.
Scoping to a component with selector gives far more useful failures:
--data-urlencode "selector=#pricing-table"The selector is capped at 255 characters and the first match is used. Note that a
selector matching nothing fails the render with capture_failed rather than falling
back — in a test suite that is a feature, since a vanished element should fail. More in
Full page and element screenshots.
Turn caching off
cache_ttl is exactly wrong here. A cache hit returns an earlier render, so a genuine
regression would be served the old, passing image. Leave cache_ttl unset in test runs —
without it, every call renders fresh.
The reverse is worth knowing too: because the cache key includes every output-affecting parameter, changing a single one of them in your test config invalidates all your baselines. Keep the capture parameters in version control next to the baselines.
Running the suite in CI
The per-minute rate limit is per key, and a suite is a burst by nature. Two habits:
- one API key per environment, so CI cannot throttle production traffic
- bounded concurrency, tuned against X-RateLimit-RemainingBack off on rate_limit_exceeded using Retry-After, but never loop on
quota_exceeded — that one lasts until the billing period rolls over. See
Rate limits and quotas and API error codes.
For a large suite, callback_url turns the run into a fan-out: fire every capture, let
them queue, collect the webhooks. The job budget is 120 seconds per capture rather than
the synchronous 30-second render timeout.
Storing baselines
Do not treat a signed url as a baseline. It expires after 24 hours, and stored captures are pruned on their own schedule — see Storage and retention. Commit the bytes, or push them to your own bucket, and diff against those.
Checklist
- Explicit
widthandheight, one baseline per breakpoint. format=png.network_idle=trueplus a modestdelay.- No
cache_ttl. - Animations, clocks and random data frozen in the page itself.
- Capture parameters version-controlled with the baselines.
- A separate API key for CI.