Canonical (v2): every setting lives on one flat object, SnapDiff.config.
# In test_helper.rb or rails_helper.rb
SnapDiff.configure do |config|
config.window_size = [1280, 1024]
config.stability_time_limit = 1
config.blur_active_element = true
config.hide_caret = true
config.tolerance = 0.0005
config.color_distance_limit = 15
endLegacy (still supported): the two-holder block, split across Capybara::Screenshot and
Capybara::Screenshot::Diff.
Capybara::Screenshot::Diff.configure do |screenshot, diff|
screenshot.window_size = [1280, 1024]
screenshot.stability_time_limit = 1
screenshot.blur_active_element = true
screenshot.hide_caret = true
diff.tolerance = 0.0005
diff.color_distance_limit = 15
end
driver:is deliberately absent from both examples. The setting is removed in 2.1 (NoMethodErrorat config time) and 2.0 cannot warn about it. Addgem "ruby-vips"and leave the selection alone — see Drivers.
SnapDiff::Config is the storage; the legacy accessors are thin delegators onto it. There is
one source of truth, so a write through either surface is visible through the other — mixing them
is safe, and you can migrate a suite one line at a time:
SnapDiff.config.window_size = [1280, 1024]
Capybara::Screenshot.window_size # => [1280, 1024]Every option name below is identical on both surfaces — only the receiver changes. The one
exception: Capybara::Screenshot.enabled is SnapDiff.config.screenshot_enabled, because
SnapDiff.config.enabled is taken by Capybara::Screenshot::Diff.enabled. See
SnapDiff — the canonical API for the full SnapDiff-native surface.
Note: Setting SnapDiff.config.screenshot_enabled = false is sufficient to disable all screenshots. There is no need to define no-op modules or monkey-patch the gem.
record is the single setting for what happens when a screenshot has no committed baseline, or
when you want to accept the ones that changed. It replaces fail_if_new, which is removed in 2.1.
SnapDiff.config.record = :once # what a local run already does; see Precedence below for CI| Mode | Missing baseline | Baseline present | Reach for it when |
|---|---|---|---|
:once |
recorded, not compared | compared | the default — you want a normal run |
:none |
fails with the git add command attached |
compared | every screenshot must already be recorded |
:all |
recorded | re-recorded, not compared | you changed the UI on purpose and want the new rendering to become the baseline |
After an intentional redesign that changed forty screenshots, re-record them in one run rather than accepting them one failure at a time:
# test_helper.rb. There is no CLI flag — the gem has no runner to hang one on —
# so gate it on an environment variable of your own if you want one:
SnapDiff.config.record = ENV["ACCEPT_SCREENSHOTS"] ? :all : :onceACCEPT_SCREENSHOTS=1 bin/rails test:system # with the line above in test_helper.rbEvery screenshot is written to its baseline path and nothing is compared, so git status lists
exactly what changed. Review the images, then commit them — see
Accepting an intentional change.
At the end of the run the gem names what it accepted:
[snap_diff] record: :all re-recorded 3 screenshots WITHOUT comparing: checkout/cart, checkout/payment, checkout/review. Review the result before committing -- an unintended change is accepted just as silently.
:allrefuses to run under CI (whenENV['CI']is set to a non-empty value). It accepts every rendering by design, so a mode left in a committed config file would buy you a build that compares nothing and passes forever, with the "recorded" screenshots discarded when the runner is torn down. Re-record locally, where you can look at the result.A CI job that needs to record screenshots with no baseline yet does not need
:all:record = :oncerecords those and still compares everything that has a baseline. See CI integration.
assert_matches_screenshot "flaky_widget", record: :none # this one must already existAn explicitly set mode outranks fail_if_new; fail_if_new decides only when no mode was set.
That is the same rule fail_if_new itself has over the CI sniff — explicit outranks implicit, all
the way down. Per screenshot outranks the config; the config outranks fail_if_new.
With nothing set, record reads back as :none under CI and :once off it — which is exactly
what fail_if_new has always done, so a suite that never mentions record behaves as it always
did. The missing-baseline default is deliberately unchanged: failing only under CI is what Jest,
AVA, Vitest, testthat and jest-image-snapshot all chose, and a screenshot baseline recorded on your
laptop is often worthless on another OS. :none makes strictness an explicit choice instead.
| You wrote | record reads |
Missing baseline |
|---|---|---|
| nothing, off CI | :once |
recorded |
| nothing, under CI | :none |
fails |
record = :once |
:once |
recorded, on CI too |
record = :none |
:none |
fails, off CI too |
record = :once, fail_if_new = true |
:once |
recorded — the mode wins |
record = nil, fail_if_new = true |
:none |
fails — nil hands it back |
A misspelt mode raises ArgumentError at the point you set it, rather than reading back as
"nobody said".
| Use Case | VIPS tolerance |
ChunkyPNG color_distance_limit |
stability_time_limit |
|---|---|---|---|
| Animated/complex pages | 0.01 | 30 | 2s |
| Standard Rails apps | 0.001 (default) | 15 | 1s |
| Pixel-perfect design tests | 0.0001 | 5 | 1s |
Note: VIPS defaults to tolerance: 0.001 (allows 0.1% pixel difference). ChunkyPNG has no default tolerance.
Important: perceptual_threshold, color_distance_limit, and tolerance serve different purposes. Use this decision tree:
| Method | Scale | Driver | Best for |
|---|---|---|---|
perceptual_threshold |
0-100+ (dE00) | VIPS only | Cross-OS/browser font rendering, anti-aliasing |
color_distance_limit |
0-510 (RGBA Euclidean) | VIPS, ChunkyPNG | Legacy setups, fine-grained RGB control |
Recommendation: Use perceptual_threshold: 2.0 for most cases. It matches human perception and needs less tuning.
perceptual_threshold and color_distance_limit cannot both be active — if you set both, perceptual_threshold wins and color_distance_limit is ignored. However, tolerance works with both methods and is applied by default for VIPS (0.001). This means even with perceptual_threshold: 2.0, the tolerance: 0.001 default still filters results.
| Setting | What it does | Scale |
|---|---|---|
tolerance |
Maximum allowed ratio of different pixels (VIPS) or diff bounding box (ChunkyPNG) | 0.0-1.0 |
Example: tolerance: 0.001 allows 0.1% of the image to differ (e.g., 125 pixels in a 1280×1024 screenshot).
Key difference:
perceptual_threshold/color_distance_limit→ "how different can a pixel be?"tolerance→ "how many pixels can differ?"
# Modern approach (recommended)
screenshot 'dashboard', perceptual_threshold: 2.0
# Allow small noise regions
screenshot 'dashboard', perceptual_threshold: 2.0, tolerance: 0.001
# Legacy ChunkyPNG setup
screenshot 'dashboard', color_distance_limit: 15Tier 1 — Zero config (works immediately):
blur_active_element and hide_caret are on by default, and record behaves as :none in CI
(a missing baseline fails) and :once off it.
Just require 'snap_diff/integrations/minitest' (legacy: capybara_screenshot_diff/minitest) and call screenshot.
Tier 2 — Set when tests are flaky:
| Setting | When to use |
|---|---|
window_size |
Screenshots differ between machines due to different browser sizes |
tolerance |
Sub-pixel rendering differences cause false positives |
skip_area |
Dynamic content (timestamps, ads) changes between runs |
stability_time_limit |
Animations or loading states cause inconsistent captures |
Tier 3 — Advanced tuning:
| Setting | When to use |
|---|---|
perceptual_threshold |
Anti-aliasing false positives across OS/browser versions |
shift_distance_limit |
Content shifts by a few pixels (ChunkyPNG only — removed in 2.1) |
area_size_limit |
Allow small diff regions below a pixel count |
color_distance_limit |
Fine-tune raw RGB channel tolerance |
median_filter_window_size |
Smooth noise before comparison (VIPS only) |
You can specify the desired screen size using
Capybara::Screenshot.window_size = [1024, 768]This will force the screen shots to the given size, and skip taking screen shots unless the desired window size can be achieved.
If you want to skip taking screen shots, set
SnapDiff.config.screenshot_enabled = falseYou can of course set this by an environment variable
SnapDiff.config.screenshot_enabled = ENV['TAKE_SCREENSHOTS']A disabled screenshot is not an assertion, and Minitest is told so: a test whose only assertion was a screenshot reports as missing assertions rather than as a pass over nothing.
If you want to skip the assertion for change in the screen shot, set
Capybara::Screenshot::Diff.enabled = falseUsing an environment variable
Capybara::Screenshot::Diff.enabled = ENV['COMPARE_SCREENSHOTS']Removed in 2.1. A screenshot that differs from its baseline fails — that is what the gem is for. To accept a difference, re-record it:
record = :all. It keeps working for the whole 2.x line and warns once per process.
To allow screenshot differences, but still fail on functional errors, you can set the following option:
Capybara::Screenshot::Diff.fail_on_difference = falseIt defaults to true. This can be useful in continuous integration to a generate a screenshot difference
report while still reporting functional errors.
Removed in 2.1, superseded by
record.record = :noneisfail_if_new = true;record = :onceisfail_if_new = false. Unlike the boolean, a mode means the same thing on CI and off it. It keeps working for the whole 2.x line and warns once per process.
To fail the test if a new screenshot is taken, set the following option:
Capybara::Screenshot::Diff.fail_if_new = trueIf fail_if_new is set to true, the test will fail if a new screenshot is taken
that does not have a corresponding previous image to compare against.
This can be useful in situations where you want to ensure
that every screenshot taken by your tests corresponds to an expected state of your application.
fail_if_new defaults to true in CI environments (when ENV['CI'] is set to a non-empty value).
Setting it yourself outranks the environment: fail_if_new = false stays false under CI=true.
Assign nil to hand it back to the environment. Setting record outranks it either way.
Removed in 2.1. It skips the test instead of saying what to do about the missing baseline.
record = :nonefails with thegit addcommand attached;record = :oncerecords the screenshot and names it in the end-of-run summary. It keeps working for the whole 2.x line and warns once per process.
To mark tests as pending (skipped) if a new screenshot is taken without a baseline, set:
Capybara::Screenshot::Diff.pending_if_new = true
# Required in CI, because fail_if_new defaults to true there and raises before
# the pending marker is applied.
Capybara::Screenshot::Diff.fail_if_new = falseIf pending_if_new is set to true, the test will be marked as skipped in teardown
when a new screenshot has no committed baseline to compare against.
This is complementary to fail_if_new (which raises immediately); fail_if_new takes precedence since it raises first.
This option is useful when you want to record new screenshots without blocking CI, but still track them as needing review.
By default, Capybara::Screenshot::Diff saves screenshots to a
doc/screenshots folder, relative to either Rails.root (if you're in Rails),
or your current directory otherwise.
If you want to change where screenshots are saved to, then there are two configuration options that that are relevant.
The most likely one you'll want to modify is ...
Capybara::Screenshot.save_path = "other/path"The save_path option is relative to Capybara::Screenshot.root.
Capybara::Screenshot.root defaults to either Rails.root (if you're in
Rails) or your current directory. You can change it to something entirely
different if necessary, such as when using an alternative web framework.
Capybara::Screenshot.root = Hanami.rootTo ensure that animations are finished before saving a screen shot, you can add a stability time limit. If the stability time limit is set, a second screen shot will be taken and compared to the first. This is repeated until two subsequent screen shots are identical.
Capybara::Screenshot.stability_time_limit = 0.1This can be overridden on a single screenshot:
test 'stability_time_limit' do
visit '/'
screenshot 'index', stability_time_limit: 0.5
endThe failure names the area that kept changing, and hands you the command that fixes it:
Could not get stable screenshot for 'index-with-ticker' within 1.2s (5 attempts).
The page kept changing in 1 area, over 4 attempt pairs:
[67,50,213,68] (left,top,right,bottom edges) -- 0.55% of the 800x600 image, changed in 4 of 4 pairs
Always the same area, in every pair: that is an animation, clock, carousel or live counter.
Exclude it and the page is stable without waiting:
assert_matches_screenshot "index-with-ticker", skip_area: [67,50,213,68]
<one annotated attempt image per line>
The coordinates are measured, not guessed: they come from the comparisons the
gem just ran between consecutive attempts, so pasting the suggested skip_area
in works.
One caveat, and it is a real one. The suggested box is the union of what changed across the attempts that ran — a sample of the animation, not a proven bound on it. If the moving thing also changes SIZE between frames (proportional text of varying width in a centred box is the common case), a later frame can render a pixel or two outside the box that was measured, and the masked run fails again with a much smaller region. Paste the new suggestion, or widen the box by a few pixels; it converges. For the usual case — a clock, spinner or counter repainting inside a fixed element — the extent does not move and the first suggestion is the whole fix.
Read the "changed in N of N pairs" line before acting on it:
- N of N — one area, every single pair. Something is animating in one place:
a clock, a carousel, a spinner, a live counter.
skip_areais the fix, and since masking no longer waits it costs nothing. - Fewer than N, or several areas each changing once. The page is still
rendering, not animating. Masking those areas would hide real content. The
message says so and suggests nothing to mask — settle the page in a
readiness block instead, or raise
wait:.
Every run that waited for stability ends with what it actually paid:
[snap_diff] 34 screenshots waited for the page to settle: 0.19s and 2 attempts at worst. Every screenshot settled on its first retry, so a lower stability_time_limit would cost less per screenshot.
Two attempts is the floor — one capture, plus the retry that matched it. Hitting
the floor across the whole run means no page was ever still moving when the
retry was taken, so every stability_time_limit sleep was spent on a page that
had already stopped. That is the evidence for tuning it down; without it,
lowering the setting is guesswork, and guesswork loses to sleep.
Run-level and silent when nothing waited, for the same reason as the never-matched-selector line: a line printed on every screenshot is a line people learn to skip.
When the stability_time_limit is set, but no stable screenshot can be taken, a timeout occurs.
The timeout occurs after Capybara.default_max_wait_time, but can be overridden by an option.
test 'max wait time' do
visit '/'
screenshot 'index', wait: 20.seconds
endIn Chrome the screenshot includes the blinking input cursor. This can make it impossible to get a
stable screenshot. To get around this you can set the hide caret option:
Capybara::Screenshot.hide_caret = trueThis will make the cursor (caret) transparent (invisible), so the blinking does not delay the screen shot.
Another way to avoid the cursor blinking is to set the blur_active_element option:
Capybara::Screenshot.blur_active_element = trueThis will remove the focus from the active element, removing the blinking cursor.
Sometimes you want to allow small differences in the images. For example, Chrome renders the same
page slightly differently sometimes. You can set set the color difference threshold for the
comparison using the color_distance_limit option to the screenshot method:
test 'color threshold' do
visit '/'
screenshot 'index', color_distance_limit: 30
endThe difference is calculated as the euclidean distance. You can also set this globally:
Capybara::Screenshot::Diff.color_distance_limit = 42Removed in 2.1.
shift_distance_limitis implemented only by the ChunkyPNG driver, and 2.1 removes that driver — libvips becomes the only backend. Setting it anywhere (SnapDiff.config.shift_distance_limit =, the legacyCapybara::Screenshot::Diff.shift_distance_limit =, orscreenshot 'index', shift_distance_limit: 2) warns once per process in 2.0. There is no vips equivalent: usemedian_filter_window_size(the faster answer to the same problem — see Drivers),tolerance, orcolor_distance_limit.
Sometimes you want to allow small movements in the images. For example, jquery-tablesorter
renders the same table slightly differently sometimes. You can set set the shift distance
threshold for the comparison using the shift_distance_limit option to the screenshot
method:
test 'color threshold' do
visit '/'
screenshot 'index', shift_distance_limit: 2
endThe difference is calculated as maximum distance in either the X or the Y axis. You can also set this globally:
Capybara::Screenshot::Diff.shift_distance_limit = 1Note: For each increase in shift_distance_limit more pixels are searched for a matching color value, and
this will impact performance severely if a match cannot be found.
If shift_distance_limit is nil shift distance is not measured. If shift_distance_limit is set,
even to 0, shift distance is measured and reported on image differences.
You can set set a threshold for the differing area size for the comparison
using the area_size_limit option to the screenshot method:
test 'area threshold' do
visit '/'
screenshot 'index', area_size_limit: 17
endThe difference is calculated as width * height. You can also set this globally:
Capybara::Screenshot::Diff.area_size_limit = 42Sometimes you have expected change that you want to ignore.
You can use the skip_area option with [left, top, right, bottom]
or css selector like '#footer' or '.container .skipped_element' to the screenshot method to ignore an area.
test 'unstable area' do
visit '/'
screenshot 'index', skip_area: [[17, 6, 27, 16], '.container .skipped_element', '#footer']
endskip_area masks what is on the page at assertion time — it does not wait.
A selector is resolved against the DOM as it is when the screenshot is taken; one
that matches nothing masks nothing, immediately. (Until 2.0 it blocked for
Capybara.default_max_wait_time per unmatched selector — 5s each, on every
screenshot. That wait is gone.)
So content that arrives late — lazy-loaded images, JS-injected widgets, anything behind an unresolved fetch — has to be settled before the assertion, or its mask will be empty and the unstable region will be compared. Settle it in the readiness block described below; the two cases people hit most (webfonts and lazy images) are written out in Recipes.
If a selector matched nothing in every screenshot of a run, the end-of-run summary names it:
[snap_diff] 1 selector never matched anything in this run: "artcile img". A selector that matches nothing masks nothing -- check for a typo or a stale selector.
That is a run-level fact on purpose. Per screenshot the gem cannot tell a typo from a page that legitimately has no images, so it says nothing; a selector that matched somewhere is doing its job and is never mentioned. Nothing is printed when every selector matched.
assert_matches_screenshot and capture_screenshot (and the screenshot /
assert_no_screenshot_changes wrappers) take an optional block. It runs once,
after the enabled check and before the capture:
test 'gallery' do
visit '/gallery'
assert_matches_screenshot 'gallery', skip_area: ['article img'] do
scroll_to :bottom
assert_text 'End of gallery'
scroll_to :top
end
endWhy a block rather than the line above it: work in the block is skipped when
screenshots are off. Both methods return immediately when
SnapDiff.config.enabled (or screenshot_enabled) is false, so a
preload_all_images written on the preceding line still pays for its browser
round-trips — scroll, wait, scroll back — for a screenshot that is never taken.
Inside the block it costs nothing, which is what makes turning visual tests off
actually free.
Errors raised in the block are yours and propagate unchanged. It is not a hook: there is no configuration-level equivalent, no after-block, and it runs once per assertion rather than once per stability retry.
In RSpec, call assert_matches_screenshot directly rather than through the
match_screenshot matcher — expect(page).to match_screenshot('x') { ... }
binds the block by Ruby's {}/do...end precedence rather than by intent, so
the matcher does not take one. In Cucumber the DSL is in the World, so step
definitions pass a block the same way a Minitest test does.
These are the workarounds people hand-roll anyway. The block is where they belong, because that is the only place they are skipped when screenshots are off.
Webfonts. A font swapping in mid-capture reflows every line of text that
uses it, so the same page renders two different ways depending on when the
screenshot lands — the classic "it only fails on CI" flake. People usually
paper over it with a skip_area, a loosened tolerance and a retry, all three
of which weaken the comparison everywhere. Wait for the swap instead:
assert_matches_screenshot 'home' do
page.evaluate_async_script(
'var done = arguments[0]; document.fonts.ready.then(function(){ done(true) })'
)
enddocument.fonts.ready resolves once every font used by the current layout has
loaded (or failed) — and on a warm cache that has already happened, so the call
returns on the first round trip. It is a Font Loading API promise, supported in
every browser Capybara drives.
There is deliberately no built-in font wait: it would be a browser round trip imposed on every screenshot in every suite, and a driver-compatibility surface the gem would own forever, in exchange for one line you can write yourself.
Lazy-loaded images. Anything behind loading="lazy", an IntersectionObserver
or an unresolved fetch is simply not there when the screenshot is taken. Force
it in, then come back:
assert_matches_screenshot 'gallery', skip_area: ['article img'] do
scroll_to :bottom
assert_text 'End of gallery'
scroll_to :top
endNote the order: skip_area: ['article img'] masks what exists at assertion
time, so the images have to be in the DOM before the mask is resolved. A
selector for content that has not loaded yet produces an empty mask and the
unstable region is compared anyway — see Skipping an area.
What does not belong here. Waits that every screenshot needs regardless
(disable_animations, hide_caret) are configuration, not readiness. And the
block runs once per assertion, not once per stability retry, so it cannot be
used to nudge a page that keeps moving — for that, see
When the page will not settle.
The arguments are [left, top, right, bottom] for the area you want to ignore. You can also set this globally:
Capybara::Screenshot::Diff.skip_area = [0, 0, 64, 48]If you need to ignore multiple areas:
screenshot 'index', skip_area: [[0, 0, 64, 48], [17, 6, 27, 16], 'css_selector .element']If you would like to override the screenshot method or for some other reason would like to skip stack
frames when reporting image differences, you can use the skip_stack_frames option:
test 'test visiting the index' do
visit root_path
screenshot :index
end
private
def screenshot(name, **options)
super(name, skip_stack_frames: 1, **options)
endYou can specify the format of the screenshots taken by setting the screenshot_format option. By default, the format is set to "png". However, you can change this to any format supported by your image processing driver. For example, to set the format to "webp", you can do the following:
Capybara::Screenshot.screenshot_format = "webp"Allow to bypass screenshot options to Capybara driver.
# To create full page screenshots for Selenium
Capybara::Screenshot.capybara_screenshot_options[:full_page] = true
screenshot('index', median_filter_window_size: 2, capybara_screenshot_options: {full_page: false})