Unit tests check one function or class, widget tests render one widget or screen without a device, and integration tests drive the whole app on an emulator or phone. Write many unit tests, fewer widget tests and a handful of integration tests.
Unit: plain test(), no rendering, milliseconds per test; for domain logic, mappers, blocs and notifiers
Widget: testWidgets() builds a widget tree on a fake clock; you tap, type and assert on what is rendered. The sweet spot for screen behaviour
Integration: integration_test runs the real app with real plugins; slowest and most fragile, so keep it for critical journeys such as login and checkout
FlutterMiddle
Testing
Unit tests, widget tests, integration tests, mocking
Questions closed
0 / 25
- 0Review
- 6Asked often
Questions
A test file has a main() with test('description', body) calls grouped by group; setUp runs before every test in its group, tearDown after each, and every test asserts with expect(actual, matcher).
**Fresh objects in setUp**: declare late variables in the group and assign them in setUp, so no test sees state left by the previous one
Matchers: equals, isA<T>(), contains, hasLength, isEmpty, throwsA(...); a failed matcher prints what was expected and what came back
One behaviour per test: arrange, act, assert, and name the test after the behaviour: 'returns 0 for an empty cart'
testWidgets gives you a WidgetTester: build the widget with pumpWidget, locate elements with find, act with tap or enterText, call pump to rebuild, and assert with expect(finder, findsOneWidget).
**Wrap in MaterialApp**: most widgets need the Directionality, Theme and MediaQuery it provides
Finders: find.text, find.byType, find.byKey, find.byIcon, find.byTooltip; prefer a ValueKey for elements whose text may change
**pump after every action**: tap only dispatches the pointer event; nothing is rebuilt until the next frame is pumped
A stub returns canned answers, a mock also records calls so you can verify them, and a fake is a small working implementation such as an in-memory repository. Write a fake when the test needs behaviour; use a mock when the call itself is the outcome.
mocktail: class MockRepo extends Mock implements Repo {}, stub with when(() => repo.load()).thenAnswer((_) async => user), check with verify(...).called(1); no code generation, the usual choice for new projects
mockito: the same idea with @GenerateNiceMocks([MockSpec<Repo>()]) and a build_runner step; keep it where a project already uses it
Fakes survive refactors: an InMemoryCartRepo does not care which of its methods your code calls, so assertions on the result stay valid after a rewrite
**mocktail any()**: for a custom argument type it needs registerFallbackValue(...) in setUpAll, otherwise it throws a Bad state error that names the type
Do not mock what you do not own: mocking Dio or its Response is brittle; wrap it in your own client interface and fake that
logPurchase was never stubbed, and an unstubbed mocktail method returns null, which cannot be returned where a non-nullable Future<void> is declared. Stub it before acting: when(() => analytics.logPurchase(42)).thenAnswer((_) async {}).
**Why null**: Mock answers every call through noSuchMethod; with no matching stub there is nothing to return but null, and the type check on the declared return type fails
**thenAnswer for futures and streams**: thenReturn with a Future or Stream throws an ArgumentError; thenAnswer builds the future per call, inside the call zone
The stub must match: when(() => analytics.logPurchase(41)) does not cover a call with 42; use any() when the argument does not matter
A fake avoids the problem: for a fire-and-forget dependency like analytics, a FakeAnalytics that appends events to a list needs no stubbing and is easier to assert on
Order has no value equality, so the Order('p1', 1) built inside verify is a different object from the one buyNow sent. mocktail compares arguments with ==, which falls back to identity, and finds no matching call.
How mocktail matches: a plain value in when or verify is wrapped in equals, which calls ==; without an override that is identical, true only for the very same instance. The message hints at it: All calls: MockOrderApi.send(Instance of 'Order')
The fix: give Order value equality: override == and hashCode, or use Equatable, freezed or a record. A value object should compare by value in production too (in a Set, as a map key, in Stream.distinct)
When you cannot change the class: match on fields: verify(() => api.send(any(that: isA<Order>().having((o) => o.qty, 'qty', 1)))), or capture the argument with captureAny and assert on its fields
Catch it next time: override toString on value classes so a failed verify prints the fields, and pin equality with a one-line test: expect(Order('p1', 1), Order('p1', 1))
Provide the scope the widget reads from, usually by wrapping it in a MaterialApp: with nothing above it, MediaQuery.of(context) throws and AppLocalizations.of(context)! crashes on null.
**MaterialApp** gives Theme, Directionality, MediaQuery, default localizations and a Navigator; add your localizationsDelegates and locale for the app's own strings
Screen size: set tester.view.physicalSize and devicePixelRatio, then addTearDown(tester.view.reset) so the size does not leak into later tests
Text scale: tester.platformDispatcher.textScaleFactorTestValue = 2, cleared with clearTextScaleFactorTestValue
Platform: variant: TargetPlatformVariant.only(TargetPlatform.iOS) on testWidgets rather than setting debugDefaultTargetPlatformOverride by hand
State scope: ProviderScope(overrides: ...) or BlocProvider.value with a fake, in the same wrapper
**One pumpApp helper**: a ten-line wrapper copied into forty tests is how test files rot
Widget tests run on a fake clock: pump renders one frame and can move time forward, pumpAndSettle pumps until no frame is scheduled, pumpFrames steps through a fixed span, and runAsync escapes to the real clock for real I/O.
**pump([duration])**: flushes microtasks and renders a frame; with a duration it advances the fake clock and fires the timers due in that window, so a five-second Future.delayed costs nothing
**pumpAndSettle()**: pumps in 100 ms steps until nothing is scheduled; right for route transitions and finite animations, but an endless one makes it fail with pumpAndSettle timed out
**pumpFrames(widget, maxDuration): pumps at a fixed interval for a known span, to record or check the frames of an animation
runAsync(() async {...})**: runs genuinely asynchronous work such as file or socket I/O, which never completes on the fake clock; futures created inside it are not completed by pump, so do the real work there and pump outside
Testing an animation: pump exact durations: 150 ms into a linear 300 ms fade, the opacity should be about 0.5
An indeterminate CircularProgressIndicator schedules a new frame forever, so pumpAndSettle never sees an idle frame and gives up after ten minutes of fake time. The spinner is already on screen after pumpWidget, so assert right away or use a single pump().
What "settle" means: no frame scheduled; a spinner, a shimmer placeholder, a controller started with repeat() or a looping Lottie never allows it
The fix: drop pumpAndSettle here; to test the loaded state, complete the future and pump() once more
The same trap later: one looping animation anywhere on the page breaks every pumpAndSettle on that page; pump explicit durations there instead
Do not raise the timeout: the animation is infinite, so more time only makes the failure slower
Scope the finder to the subtree or the widget you actually mean. tester.tap and findsOneWidget both demand exactly one match, and .first just follows whatever the layout happens to put first.
**find.descendant(of: ..., matching: ...): limits the search to one subtree; the usual fix when a dialog repeats a label from the page behind it
find.widgetWithText(FilledButton, 'Save'): says "the button with this text", not the app bar title
A ValueKey with find.byKey: the stable answer for list rows that all look alike
Why there were two**: find.text matches every Text with equal data; offstage widgets are already skipped, so both really are on screen
Say the number: findsExactly(2) documents intended duplication, while .first hides it and silently re-targets the day someone reorders the tree
ListView.builder only builds the rows that fit the 800×600 test surface plus a small cache, so row 40 does not exist in the tree and no finder can see it. Scroll first, the way a user would.
**tester.scrollUntilVisible(finder, 200, scrollable: ...)**: drags in steps until the finder matches, bounded by maxScrolls
**tester.drag(find.byType(ListView), const Offset(0, -600))** followed by pump(): a fixed scroll when you want control over the frames
Rows in the cache extent: built but outside the viewport, so finders treat them as offstage and skip them unless you pass skipOffstage: false; a tap there misses and only prints a warning
**ensureVisible**: works only for a widget that is already built, such as a field inside a SingleChildScrollView
Or resize the surface: tester.view.physicalSize with addTearDown(tester.view.reset) when the test is about a long form, not about scrolling
Assert that the destination is on screen after the tap: pump a MaterialApp with your routes, tap, pumpAndSettle to finish the transition, then expect(find.byType(CheckoutPage), findsOneWidget). That tests behaviour, so it survives a switch from Navigator.push to named routes.
With go_router: build a GoRouter with only the routes under test and your real redirect, pump MaterialApp.router, then assert the destination
When the destination is too heavy to build: register a mocktail NavigatorObserver and verify(() => observer.didPush(any(), any())); registerFallbackValue a fake Route first
The design point: a widget that takes an onContinue callback instead of calling Navigator itself is trivial to test: assert the callback fired
**Do not test that Navigator works**: test that your widget asks for the right destination
Make the test body async and await futures directly; for a stream, attach expectLater(stream, emitsInOrder([...])) before triggering the work, then await it. The matcher consumes events as they arrive, so no delays are needed.
Futures: expect(await repo.load(), user), or await expectLater(repo.load(), throwsA(isA<TimeoutException>())) for a failure
Stream matchers: emits, emitsInOrder, emitsThrough (skips until a match), emitsInAnyOrder, emitsError, emitsDone, neverEmits
Listen before acting: cubit.stream is a broadcast stream and does not replay what was emitted before you subscribed
**Why await expectLater**: the runner waits for a pending async matcher anyway, but without await the lines after it run before the stream has matched
**emitsDone needs a closed stream: close the controller or the cubit, or the matcher waits until the 30-second test timeout
StreamQueue** from package:async reads one event at a time when a long matcher list gets hard to follow
Never sleep in a test. Run the code inside fakeAsync and move time forward yourself with elapse, so a five-minute backoff takes microseconds.
**fakeAsync((async) { ... })**: timers and delayed futures created inside do not fire until you call async.elapse(...)
**async.flushMicrotasks(): drains pending microtasks without moving the clock
Widget tests**: tester.pump(duration) already advances a fake clock, so you rarely need fake_async there
**pumpAndSettle waits for frames, not timers**: a periodic timer that keeps calling setState (or a repeating animation) means it never settles; pump explicit durations instead
Inject time: read the time through clock.now() from package:clock instead of DateTime.now(); inside fakeAsync it follows the fake clock, elsewhere withClock pins it
The trap: real I/O inside fakeAsync never completes, because its result does not arrive on the fake timeline; the assertions after it may never run
Fake your repository for widget and bloc tests, and swap the HTTP client itself only when the code under test is the HTTP layer. A test that reaches a real server is slow, flaky and can change real data.
Fake the repository: a hand-written class implementing your data-source interface; the widget test never learns HTTP exists
**package:http**: inject a Client and pass MockClient from package:http/testing.dart, which answers each Request with a Response you build
Dio: set dio.httpClientAdapter to DioAdapter from http_mock_adapter, or add an Interceptor that resolves requests with canned responses
If you forget: inside testWidgets the binding installs HttpOverrides, so every real request gets status 400 and a warning; the failure looks like a server error
Keep production interceptors: a repository test that asserts on the auth header must run the same interceptor stack as the app
Replace what the state layer depends on, not the widgets: override providers in a ProviderScope, or hand the screen a mocked bloc through BlocProvider.value, then assert on what is rendered.
Riverpod: ProviderScope(overrides: [repoProvider.overrideWithValue(FakeRepo())]) around the screen; for logic alone, a ProviderContainer with the same overrides and no widget tree
BLoC: MockBloc from bloc_test plus whenListen(bloc, stream, initialState: ...) feeds the screen exact states without running the real bloc
Split the work: state transitions in fast unit tests (blocTest, ProviderContainer), the rendering of each state in widget tests
A fresh scope per test: a container or bloc reused across tests carries state over
Assert what the user sees: texts, enabled buttons, dispatched events; not private fields of the notifier
A golden test renders a widget, compares the pixels with a committed PNG through matchesGoldenFile, and fails on any difference; flutter test --update-goldens regenerates the image after an intended change.
One operating system: font rasterisation differs between macOS, Linux and Windows, so generate and check goldens on one OS, usually the Linux CI image or a Docker container
Real fonts: tests draw text with the FlutterTest font, where every glyph is a box; load your fonts (or use alchemist) when the text itself matters
Determinism: fixed surface size, no network images, a frozen clock, finished animations
Tolerance: subclass LocalFileComparator and accept a small diffPercent when anti-aliasing noise is unavoidable
Keep them out of the fast loop: tag the file with @Tags(['golden']), declare the tag in dart_test.yaml, run flutter test --exclude-tags golden locally and --tags golden in a CI job
Tooling: alchemist for theme and device matrices; golden_toolkit is discontinued
integration_test runs ordinary testWidgets code against the real app on an emulator or device, with real plugins and the real engine; patrol builds on it to also drive native UI such as permission dialogs and notifications.
Setup: files in integration_test/, IntegrationTestWidgetsFlutterBinding.ensureInitialized() first, then start the app through a test entry point that injects a fake backend; run with flutter test integration_test -d <device>
The limit: integration_test only sees Flutter's own widgets: no system permission dialog, native picker or push prompt
patrol: patrolTest plus a native automator for exactly those dialogs, and better isolation between tests; heavier to set up, so use it for the flows that touch native UI
Determinism: fake or locally host the backend, wipe storage between tests, and wait for conditions instead of fixed Future.delayed pauses
Where they run: an emulator job in CI and Firebase Test Lab for a device matrix; keep the suite to critical journeys, because each test costs minutes
flutter test --coverage writes coverage/lcov.info; strip generated files from it, then gate in CI on coverage of the changed lines rather than on a fixed total percentage.
Viewing it: genhtml renders an HTML report locally; Codecov or SonarQube comment the numbers on the pull request
Exclude generated code: *.g.dart, *.freezed.dart and l10n output carry none of your logic, and thousands of their lines drown the signal
**// coverage:ignore-line**, ignore-start / ignore-end and ignore-file: for code that genuinely cannot run in a test
Gate the patch: for example 80% on new lines; a global threshold punishes the team for legacy code and rewards trivial tests
Untested files are invisible: a file no test imports is not in lcov.info at all, so the percentage looks better than it is
What it cannot tell you: a line ran, not that anything was asserted about it
They asserted on how the code works instead of what it does, so a refactor (the very thing tests are supposed to make safe) broke them.
**verify(() => repo.fetchPage(1)) everywhere**: pins the call sequence; keep verify for effects with no other trace, such as an analytics event, and assert the visible outcome elsewhere
**find.byType(Padding).at(3): encodes the shape of the widget tree; put a key on the one node the test is about
Assert on results: check what the screen shows or returns, not which methods were called
One pumpApp helper and test-data builders: a new dependency or a new required field is then one edit, not thirty
Name tests by behaviour**: "shows the empty state when the cart is empty"; a test you cannot name that way is usually testing structure
Red, green, refactor: write a failing test for the next behaviour, make it pass with the simplest code, then clean up. The bigger payoff is design: code written test-first ends up with injected dependencies and small units that need no widget tree.
Dependencies come through the constructor: a test cannot replace a Dio() created inside a method, so test-first code takes interfaces as parameters
Logic leaves the widget: validation, formatting and state transitions move into plain Dart classes, notifiers or blocs that a unit test reaches directly
Time and randomness become inputs: a clock or a Random passed in, instead of DateTime.now() buried in logic
Where it fits best: domain rules, parsers, state machines and bug fixes: first a failing test that reproduces the bug
Where it fits worse: exploratory UI and layout work; there teams usually build first and then lock the result with widget or golden tests
The honest interview answer: say where you use it and where you do not; dogma in either direction is a red flag
Claim it with tester.takeException(). The test binding records the error the framework reported during the test and fails the test at the end unless you took it.
How it gets there: an exception in build, layout or paint is caught by the framework and reported through FlutterError.onError; in build, the widget is replaced by an ErrorWidget
**expect(tester.takeException(), isA<FormatException>())**: returns the pending exception and clears it; expect(tester.takeException(), isNull) asserts that nothing went wrong, for example no overflow
Only one is held: a second exception turns the pending one into a Multiple exceptions (2) were detected… error, so a test should provoke exactly one
**A plain failing Future** needs no binding: await expectLater(repo.load(), throwsA(isA<TimeoutException>()))
Testing your fallback UI: set ErrorWidget.builder in the test and restore it in a finally at the end of the test body, not in addTearDown (the binding checks it before tear-downs run); assert on your widget, and still take the exception
**Never silence FlutterError.onError across the suite**: an unclaimed exception is the framework reporting a real bug
Suspect leaked shared state before timing: tests inside one file share globals, so a test that passes alone and fails after another one is reading what the earlier test left behind.
Reproduce the order: flutter test --test-randomize-ordering-seed=random shuffles tests within each file and prints the seed; re-run with that seed locally
Isolate the suspect: run it alone with --plain-name; green alone and red inside the file means a leak from an earlier test
The usual leaks: a service locator filled in setUpAll, static caches and singletons, a mock channel handler left installed, an overridden platform instance, a tester.view size never reset; undo each with addTearDown right next to the setup
Across files: test files run in parallel processes, so a fixed temp path, a port or a fixture file written by two files collides; use Directory.systemTemp.createTempSync per test
Genuine timing: DateTime.now(), the time zone and the locale differ on CI; inject a clock and pin Intl.defaultLocale
**retry: hides the bug**: use it only alongside an open ticket, or the pipeline is green and the flake permanent
The fakeAsync callback is async, so it stops at await session.start() and hands back a future nobody waits for. The fake zone runs microtasks only on elapse or flushMicrotasks, so the rest never runs: no expect executes and the test is green.
Why it stops: fakeAsync runs the callback synchronously in a zone that holds timers and microtasks until you advance it; the code after await is such a microtask, and once the callback has returned, nothing advances the zone
Why it is green: the test body is synchronous and ends as soon as fakeAsync returns; a test with no failed expect passes, including one that asserted nothing
The fix: a synchronous callback: call session.start() without await, drain it with async.flushMicrotasks(), then elapse. Inside fakeAsync time moves only through async, never through await
Assert the state before too: expect(session.isExpired, isFalse) before elapse proves the timer flipped the flag, not the setup
Catch it next time: watch every new test fail once (break the code or the expected value) before trusting green; a test that cannot go red is the bug
Push most checks down to fast unit tests of the domain and state layers, cover every screen's states with widget tests against fakes, guard the design system with goldens, and keep a small set of integration tests for the journeys that make money.
Layers
Unit, the bulk: repositories over a faked HTTP client, mappers, use cases, blocs and notifiers; they run in seconds and pin the business rules
Widget: each screen in its loading, empty, error and data states, fed by fake repositories; navigation and form validation are tested here
Golden: design-system components and a few key screens in light and dark themes and at large text scale, rendered on one OS
Integration: login, purchase, onboarding; on an emulator per PR or nightly, on a device farm before release
What to fake
Fake at your own boundaries: repositories, wrappers around plugins, the clock; never the framework itself
Share fakes and test-data builders in one test_helpers package, so forty features do not each write their own
Recorded JSON fixtures for the API layer catch backend changes that hand-written fakes would hide
Keeping it fast and trusted
A pumpApp helper, so a new app-wide dependency is one edit
Tags split the fast PR loop from golden and integration jobs; in a monorepo, run only the packages a change can affect
Treat flaky tests as bugs with owners: a suite people re-run until green has stopped being a signal
Coverage of changed lines as a review aid, not a target