Picto is designed to stay out of your test suite's way. There is no separate testing package to install, no import to add, and nothing to mock — everything below works with the
package:picto/picto.dart you already have.
Your main() stays callable from a widget test#
flutter_test constructs its own WidgetsBinding before any test body runs, and Flutter allows only one binding per process. Because picto has to
be the binding to see draw calls, that used to be an outright conflict — Picto.setup()
threw Binding is already initialized to AutomatedTestWidgetsFlutterBinding and there was no way to boot an app that used picto from a test.
It now detects the existing binding and stands down, so this works:
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
testWidgets('the app boots', (tester) async {
app.main(); // the real main(), Picto.setup() and all
await tester.pumpAndSettle();
expect(find.byType(HomePage), findsOneWidget);
});
}
You don't need a separate test entrypoint, a kIsTest flag, or a wrapper around main().
The debug "not recording" error does not fire here#
Debug builds of a real app throw a FlutterError after the first frame when picto is not the live binding — see
nothing is being recorded. That error never fires in a widget test, and you do not need to suppress, catch, or configure anything to keep it out of your suite.
The reason it can tell the difference: flutter_test's binding descends from TestWidgetsFlutterBinding, which is not a
WidgetsFlutterBinding, and the check only escalates for the latter. Standing down under a test binding is picto working correctly, not a misconfiguration — so treating it as an error would fail every test that calls your
main(), which is the exact thing the stand-down exists to allow.
Draw-call capture is inert under test#
Standing down has a consequence worth being explicit about: the interception lives in the binding, so with
flutter_test's binding in place nothing is captured. That is the right trade — a widget test asserts on your widgets, not on picto's wire format — but it is not something to discover by surprise, so it's directly observable:
expect(Picto.isRecordingBindingInstalled, isFalse);
Sessions themselves still start and stop normally, so code paths that record are still exercised. The two states are reported separately rather than conflated:
Picto.startRecording();
expect(Picto.isRecording, isTrue); // the session did start
expect(Picto.isRecordingBindingInstalled, isFalse); // but no draw calls land
The privacy widgets are transparent under test#
ExcludeFromRecording, CaptureRealImages and CaptureVectorGraphics
render their child exactly as normal and record nothing when no session is active. Wrapping a screen for privacy never changes that screen's tests:
testWidgets('the payment form still renders', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: ExcludeFromRecording(child: PaymentForm())),
);
expect(find.byType(PaymentForm), findsOneWidget); // unaffected
});
CaptureVectorGraphics in particular holds no timer and schedules no rasterization while nothing is recording, so it will not trip
flutter_test's "A Timer is still pending after the widget tree was disposed" check.
A thrown error leaves no timer behind#
Outside a test, the first uncaught error of a session arms a 30-second timer that ends and uploads the session — The Error Capture Window explains why. That timer is not armed under a test binding.
This is the same stand-down as everything else on this page, and it is not tidiness: a testWidgets
test whose body throws would otherwise end with a live timer, and flutter_test fails a test for exactly that —
Timer (duration: 0:00:30.000000) ... still pending
So a test that deliberately throws — an error-path test, a widget that asserts, a rethrowing Future
— passes or fails on its own merits, with nothing left pending on picto's account. The upload that timer would eventually attempt is a no-op under test anyway, since nothing intercepted the session's draw calls.
Nothing is ever uploaded from a test#
endSessionAndUpload() returns false — or, if you ask for the outcome with endSessionAndUploadOutcome(),
PictoSessionEndOutcome.recordingPipelineAbsent, which distinguishes this from a genuine upload failure — without making a request, writing a file, or queueing anything.
retryPendingUploads() is a no-op and leaves a real queue — one left behind by an actual device run — completely untouched, so a later real launch still drains it.
The SDK's own automatic drains stand down in exactly the same way. Picto.setup
drains the queue at launch, and foregrounding the app drains it again (Uploading a Session
covers both), but neither does anything in a widget test — including the test that calls your main(), and including the lifecycle events a
testWidgets body dispatches. A queue left on your machine by a real device run is not uploaded, not deleted, and not touched.
testWidgets('the report-a-bug button works', (tester) async {
await tester.tap(find.byKey(const Key('report-bug')));
await tester.pumpAndSettle();
// Whatever that button calls, no request left the machine.
});
This is not test-runner detection. Uploading is gated on the recording pipeline being wired up at all — and a recording that no binding captured is empty by construction, so sending it would be wrong in any build mode, not just under test. The rule is identical in debug, profile and release.
The practical guarantee: your CI cannot put rows in your production dashboard, no matter which code paths your tests exercise.
Integration tests#
IntegrationTestWidgetsFlutterBinding is installed before your test runs, so picto stands down there too — nothing is captured and, importantly, nothing is uploaded even though a real device running
integration_test does have working network access. Recording is exercised by running your app for real, not by the test harness.
What picto never does to your suite#
- No network calls that hang — a failed upload is queued, not retried in a loop.
- No background timers left running after a test.
- No global state that leaks between test files: each file is its own process.
-
No requirement to call anything in
setUp/tearDown.Picto.setup()is safe to call more than once, and safe never to call at all.