Snaptest
See what your widgets look like during testing.

Snaptest is simple: call snap() in any widget test to save a screenshot of what's currently on screen. Perfect for debugging, documentation, and visual regression testing.
Installation
dart pub add dev:snaptest
The Basics
Just call snap() to see your screen
Add one line to any widget test to see what it looks like:
import 'package:flutter_test/flutter_test.dart';
import 'package:snaptest/snaptest.dart';
testWidgets('My widget test', (tester) async {
await tester.pumpWidget(const MaterialApp(home: MyPage()));
// That's it! Screenshot saved to .snaptest/
await snap();
});
The screenshot gets saved as a PNG file in .snaptest/ using your test name. Great for debugging failing tests or documenting what your widgets actually look like.
Set up font loading
Snaptest blocks text by default, replacing characters with colored rectangles for cross-platform consistency. However, the layout of text still depends on which fonts are loaded. To avoid golden churn when fonts change between environments, load fonts upfront in your flutter_test_config.dart:
// flutter_test_config.dart
import 'dart:async';
import 'package:snaptest/snaptest.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
await loadFonts();
await testMain();
}
This loads Roboto from the Flutter SDK and uses it for Cupertino system fonts, ensuring consistent text layout across macOS, Linux, and Windows. See Font Rendering for customization options.
Configure your .gitignore
**/.snaptest/ # Screenshots (usually not committed)
Settings
By default, snap() uses SnaptestSettings.rendered() which produces beautiful screenshots with real text, images, shadows, and device frames. Perfect for debugging and documentation.
For simpler, more deterministic screenshots (e.g. for faster tests or when you don't need visual fidelity), use SnaptestSettings():
await snap(settings: const SnaptestSettings());
This blocks text (colored rectangles), disables images, shadows, and device frames.
Golden File Testing
Snaptest provides three distinct methods for different use cases:
snap.golden() — golden comparison only
Takes a golden comparison screenshot without saving a visual debugging file. The golden is rendered with default SnaptestSettings() (blocked text, no shadows) for cross-platform consistency.
testWidgets('Golden only', (tester) async {
await tester.pumpWidget(const MaterialApp(home: LoginScreen()));
await snap.golden(device: Devices.ios.iPhone16Pro);
});
snap.andGolden() — visual debugging + golden comparison
Takes both a visual debugging screenshot (saved to .snaptest/) and a golden comparison screenshot. Returns the saved file.
testWidgets('Both snap and golden', (tester) async {
await tester.pumpWidget(const MaterialApp(home: LoginScreen()));
await snap.andGolden(
device: Devices.ios.iPhone16Pro,
settings: SnaptestSettings.rendered(), // for the visual snap
);
});
The visual snap uses your settings (or SnaptestSettings.global), while the golden uses goldenSettings (or the default SnaptestSettings()).
Updating goldens
When golden tests fail due to intentional changes, update them:
flutter test --update-goldens
Getting an Image directly
Use snap.image() to get an Image without saving to disk. Same pipeline as snap() but returns the image for custom processing:
testWidgets('Get image', (tester) async {
await tester.pumpWidget(const MaterialApp(home: MyPage()));
final image = await snap.image(
device: Devices.ios.iPhone16Pro,
settings: SnaptestSettings.rendered(),
);
// Use the image for custom processing...
});
Recording Videos
snap.recordVideo() records a video of what your test actually does — it passively captures the frames your test already paints, so the recording shows the real test timeline. Great for demos, documentation, and debugging visual regressions.
This feature is experimental and requires FFmpeg on
PATH.
Setup
Recording requires the observing binding, installed before any other binding use — put it in flutter_test_config.dart:
// flutter_test_config.dart
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
SnaptestWidgetsFlutterBinding.ensureInitialized();
await loadFonts();
await testMain();
}
The binding is inert unless a recording is active, so it's safe to install globally.
Basic usage
testWidgets('open details', (tester) async {
final recording = await snap.recordVideo();
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('Details'));
await tester.pumpAndSettle();
final file = await recording.stop(); // .mp4 by default; .webm for VP9
});
If you never call stop(), the recording stops automatically when the test body completes. One recording can be active per test — stop() before starting another.
Settings
final recording = await snap.recordVideo(
name: 'my_demo',
settings: const SnapVideoSettings(
frameRate: 60, // output fps (default)
timing: VideoTiming.smooth, // or VideoTiming.observed
encoding: VideoEncoding.h264(crf: 20),// H.264 MP4, yuv420p, +faststart
// encoding: VideoEncoding.vp9(), // WebM with alpha (yuva420p)
// backgroundColor: Color(0xFF000000), // behind the frame; null = transparent
// on alpha codecs, opaque white else
includeDeviceFrame: true, // render inside the device frame
showPointers: true, // touch indicator at held pointers
// pointerIndicator: (canvas, center, scale) {
// // fully custom indicator; scale = logical-to-image px
// },
finalHold: Duration(milliseconds: 400), // linger on the last frame
),
);
VideoTiming.observed(default): every pump runs unchanged; painted states are shown at their test-clock timestamps and the last state is held across gaps. Zero impact on test execution — best for debugging.VideoTiming.smooth: positive-duration pumps are subdivided into frame-sized steps so animations appear smooth at the simulated speed. This runs more frames than the test otherwise would (listeners, timers, and physics may behave differently) — best for demos and documentation.VideoEncoding.vp9()produces a WebM with an alpha channel (libvpx-vp9+yuva420p, CRF default 32). H.264 is better for sharing everywhere; VP9 for compositing or posting to tools that preserve transparency.backgroundColordefaults to transparent on alpha-capable encodings (VP9) and opaque white otherwise. Pass any color to flatten the video onto it.showPointersdraws a ring-and-dot touch indicator at each pointer while it is held, like a "show touches" overlay — great for demos that visualize gestures. A quick tap between pumps paints no indicator; drags, flings, and multi-step gestures track the dot across frames.pointerIndicatorreplaces the indicator painting entirely:(canvas, center, scale)is called per held pointer per frame;centeris in image coordinates,scaleconverts logical pixels to image pixels. Setting it also enables indicators.includeDeviceFramecomposites each frame inside the device frame of the activeTestDevicesVariantdevice. Combine it with a device variant for ready-to-share recordings:
testWidgets(
'demo',
variant: TestDevicesVariant(
{Devices.ios.iPhone16Pro},
orientations: {Orientation.portrait},
),
(tester) async {
await snap.recordVideo(
settings: const SnapVideoSettings(
timing: VideoTiming.smooth,
includeDeviceFrame: true,
),
);
// ...
},
);
Pitfalls
The recorder faithfully captures what the test paints — most "the video jumps" issues are test-scripting artifacts, not recording bugs:
-
Single
moveByteleports.tester.drag()sends onemoveByevent, so a draggable (e.g. a sheet) jumps to its end position in a single frame. Drive drags withstartGesture, incrementalmoveBy+ smallpumps in a loop, thenup():final gesture = await tester.startGesture(tester.getCenter(sheetFinder)); for (var i = 0; i < 12; i++) { await gesture.moveBy(const Offset(0, 30)); await tester.pump(const Duration(milliseconds: 16)); } await gesture.up(); -
In observed mode, a big
pumprecords only its end state.pump(const Duration(seconds: 1))paints once — slide-ins, dismissals, and settle animations in between are skipped and look like jumps. Pump in frame-sized steps (15 × 40ms) around transitions, or useVideoTiming.smoothwhich subdivides for you. -
Drags inside a scrollable scroll it. Sheet-style drag-to-dismiss only takes over at the scroll boundary (e.g. the top). Scroll back to the boundary before the dismiss gesture, or start the gesture on non-scrollable chrome like an
AppBar. -
dart:iofutures never complete in the test's zone. Widget tests run in aFakeAsynczone; async IO must go throughbinding.runAsync(the encoder already does this internally). Prefer synchronous IO (readAsBytesSync,writeAsBytesSync) inside test bodies. -
Runaway pumps are capped.
maxSimulatedDuration(1 minute of simulated time by default),maxCapturedFrames, andmaxSpoolBytesfreeze capture and mark the artifact.truncatedif apumpAndSettleloop runs long. Raise them for long demos.
All the Options
Multiple screenshots per test
When snap() is called multiple times in the same test, a counter suffix is automatically added:
testWidgets('User flow', (tester) async {
await tester.pumpWidget(const MaterialApp(home: MyPage()));
await snap(); // my_test.png
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
await snap(); // my_test_2.png
});
You can also provide explicit names:
await snap(name: 'initial_state');
await snap(name: 'after_tap');
Capture specific widgets
await snap(from: find.byKey(const Key('my-card')));
from chooses what gets snapped. snaptest walks up to the nearest
RepaintBoundary and captures that boundary.
Use from when:
- You want to change the capture source.
- You want to snap a widget or subtree instead of the whole screen.
- You care about the widget's own repaint boundary more than surrounding UI.
If you're thinking "snap this widget," use from.
Crop the result
final cardFinder = find.byKey(const Key('my-card'));
await snap(crop: tester.getRect(cardFinder));
crop trims the snapped result to a Rect in Flutter logical coordinates. It
uses the same coordinate space as tester.getRect(finder), including when
device frames are enabled.
Use crop when:
- You want to keep the current capture source, but trim the final image.
- You want a widget-shaped image while keeping its background or ancestors.
- You want overlays, dialogs, tooltips, or menu entries that are rendered outside the widget's own repaint boundary to appear in the result.
For example, this captures the whole screen and then crops to the card's bounds:
final cardFinder = find.byKey(const Key('my-card'));
await snap(crop: tester.getRect(cardFinder));
That is different from from: cardFinder: from captures the card's nearest
RepaintBoundary, while crop keeps screen-level visuals and only trims the
output bounds.
If device framing is enabled, keep passing logical rects from Flutter. snaptest maps the rect into the framed device's screen area for you.
You can also combine both features:
await snap(
from: find.byKey(const Key('card-boundary')),
crop: tester.getRect(find.text('Confirm')),
);
Use both when:
- You want to snap a specific source selected by
from. - But you only want to keep a smaller region inside that snapped result.
If you're thinking "snap from here, but only keep this part," use both.
Override global settings
void main() {
setUpAll(() {
// Use simplified screenshots for this test file
SnaptestSettings.global = const SnaptestSettings();
});
tearDownAll(SnaptestSettings.resetGlobal);
}
Dedicated screenshot tests with snapTest
For tests specifically designed for screenshots, use snapTest instead of testWidgets. It automatically:
- Adds the
snaptesttag for easy filtering - Applies custom settings for the entire test
import 'package:flutter/material.dart';
import 'package:snaptest/snaptest.dart';
snapTest('Login screen looks correct', (tester) async {
await tester.pumpWidget(const MaterialApp(home: LoginScreen()));
await snap();
});
snapTest(
'Multi-device homepage',
devices: {Devices.ios.iPhone16Pro, Devices.android.samsungGalaxyS20},
(tester) async {
await tester.pumpWidget(const MaterialApp(home: HomePage()));
await snap();
},
);
Run only screenshot tests:
flutter test --tags snaptest
Or exclude them from regular test runs:
flutter test --exclude-tags snaptest
Test multiple devices and orientations with TestDevicesVariant
For running the same test across multiple devices and orientations:
testWidgets(
'Responsive layout test',
variant: TestDevicesVariant(
{
Devices.ios.iPhone16Pro,
Devices.ios.iPad,
Devices.android.googlePixel9,
Devices.android.largeTablet,
},
orientations: {
Orientation.portrait,
Orientation.landscape,
},
),
(tester) async {
await tester.pumpWidget(const MaterialApp(home: MyPage()));
await snap.golden();
},
);
This runs your test once for each device/orientation combination (8 times in this example). Each test run:
- Sets the test view to the specified device size
- Configures the correct platform (iOS/Android)
- Runs in the specified orientation
- Labels the test with the device and orientation name
snap() parameters
All methods on snap accept these common parameters:
await snap(
name: 'custom_name', // Custom filename
from: find.byKey(key), // Choose what gets snapped
crop: tester.getRect(find.byKey(key)), // Trim the snapped result
settings: SnaptestSettings(), // Override global settings
device: Devices.ios.iPhone16, // Device to simulate
orientation: Orientation.portrait,
);
from and crop answer different questions:
from: What should be snapped?crop: Which logical part of that snapped image should be kept?
As a rule of thumb:
- Start with
fromif you want to change the source. - Start with
cropif you want to change the bounds of the final image. - Use
crop: tester.getRect(finder)for the common "crop to this widget, but still include backgrounds and overlays" case. - Device framing does not change the coordinate space for
crop; keep using Flutter logical coordinates.
snap.golden() and snap.andGolden() additionally accept:
await snap.golden(
prefix: 'goldens/', // Golden files directory (default)
);
await snap.andGolden(
settings: SnaptestSettings.rendered(), // For the visual snap
goldenSettings: SnaptestSettings(), // For the golden (default)
prefix: 'goldens/',
);
Settings Reference
SnaptestSettings options:
blockText: Replace text with colored rectangles for consistency (default:true)renderImages: Render actual images (default:false)renderShadows: Render shadows and elevation effects (default:false)includeDeviceFrame: Include device frame around content (default:false)pathPrefix: Directory where screenshots are saved (default:'.snaptest/')
Convenience constructors:
SnaptestSettings.rendered(): The default — actual text, images, shadows, and device framesSnaptestSettings(): Simplified — blocked text, no images/shadows/frames
Golden Tools
For advanced use cases, import golden_tools.dart to access lower-level utilities:
import 'package:snaptest/golden_tools.dart';
This exports:
captureImage()— render an Element to aui.Imagewith optional blocked text and device frameBlockedTextPaintingContext/BlockedTextCanvasAdapter— replace text rendering with colored rectanglesloadFont()/loadFonts()— load fonts into the test environmentprecacheImages()— pre-cache images for accurate renderingsetTestViewForDevice()— simulate a device's screen size and safe areas
Helper Scripts
Clean all screenshots:
dart run snaptest:clean
Clean screenshots from a custom directory:
dart run snaptest:clean my_custom_dir
You can also call cleanSnaps() programmatically. Note that it deletes all .snaptest/ directories, so run it once before your full test suite (e.g. in a script), not inside flutter_test_config.dart — that runs per test file and would wipe snapshots from earlier files.
Assemble screenshots into a single directory:
dart run snaptest:assemble
Custom Screenshot Directories
You can customize where screenshots are saved by setting the pathPrefix in your settings:
await snap(
settings: SnaptestSettings(pathPrefix: 'my_screenshots/'),
);
The helper scripts will work with any custom directory name you specify.
Font Rendering
Cupertino Fonts
Flutter's iOS/Cupertino typography uses the CupertinoSystemText and CupertinoSystemDisplay font families. By default, loadFonts() overrides these with Roboto from the Flutter SDK, ensuring identical text metrics on macOS, Linux, and Windows. This is the recommended setup for golden tests that run on CI.
You can customize this behavior with CupertinoFontConfig:
// Default: Roboto for cross-platform consistency (recommended for goldens)
await loadFonts();
// Use a custom font (must be declared in pubspec.yaml)
await loadFonts(
cupertinoFonts: CupertinoFontConfig.override(fontFamily: 'Inter'),
);
// Use SF Pro on macOS for visual debugging (not recommended for goldens)
// Throws if not on macOS or SF Pro is not installed.
await loadFonts(
cupertinoFonts: CupertinoFontConfig.fromMacOsSystemFonts(),
);
// Use SF Pro on macOS, fall back to Roboto elsewhere
// WARNING: can lead to inconsistencies in goldens between platforms
await loadFonts(
cupertinoFonts: CupertinoFontConfig.fromMacOsSystemFonts(
fallbackOverride: 'Roboto',
),
);
macOS: SF Pro Fonts
For the most accurate iOS rendering on macOS, install Apple's SF Pro fonts:
- Download SF Pro fonts from Apple's developer site
- Install the fonts to
/Library/Fonts(system-wide installation) - Use
CupertinoFontConfig.fromMacOsSystemFonts()in yourloadFonts()call
Note that SF Pro is licensed by Apple for creating mock-ups of iOS, iPadOS, macOS, and tvOS interfaces only. It cannot be redistributed or used on non-Apple platforms.
Font Limitations
Due to Flutter's test environment limitations:
- Google Fonts only work if bundled as local assets (not fetched remotely)
- Custom fonts must be included in your
pubspec.yaml
This ensures consistent screenshots across environments, but may differ slightly from your actual app.
Libraries
- golden_tools
- Lower-level snaptest utilities for custom screenshot workflows.
- snaptest
- Snap photos in your widget tests.