Mocking and Dependency Injection in Flutter Tests
Design Flutter code for constructor injection, pick fakes over mocks where it matters, run mockito codegen, and swap dependencies in Provider or Riverpod tests.
Published on • October 16, 2026
AI Assistant

A Flutter test suite is only as injectable as your constructors. If a widget or view model reaches out and constructs its own repository, there is nothing a test can swap in, and you end up either hitting a live web service or deleting the test. This post walks the whole loop: how to shape classes so dependencies arrive through constructors, when to hand-write a fake instead of generating a mock, the exact mockito codegen workflow, and how to inject test doubles through Provider and Riverpod.
Injection is a layering rule, not a library
Flutter’s app architecture case study makes the wiring explicit: a view knows exactly one view model, a view model knows one or more repositories passed into its constructor, and a repository knows services passed into its constructor. Nobody knows upward.
class HomeViewModel extends ChangeNotifier {
HomeViewModel({
required BookingRepository bookingRepository,
required UserRepository userRepository,
}) : _bookingRepository = bookingRepository,
_userRepository = userRepository;
final BookingRepository _bookingRepository;
final UserRepository _userRepository;
}
Two details do the testing work here. The dependencies arrive as constructor arguments, so a test can pass anything that satisfies the type. And they are stored in private fields, so the view cannot call the repository directly and create a hidden second path around your seams.
Object creation itself is handled by dependency injection at the root of the widget tree. The Compass sample uses package:provider, which Google’s teams recommend for this job:
runApp(
MultiProvider(
providers: [
Provider(create: (context) => ApiClient()),
ChangeNotifierProvider(
create: (context) => AuthRepositoryRemote(
authApiClient: context.read(),
apiClient: context.read(),
) as AuthRepository,
),
Provider(
create: (context) =>
DestinationRepositoryRemote(apiClient: context.read())
as DestinationRepository,
),
],
child: const MainApp(),
),
);
Services exist only so they can be injected into repositories with context.read, and repositories exist so they can be injected into view models. In production you create the real implementation once, at the top; in a test you create the fake implementation and inject it the same way.
Pick the test layer first
The testing overview sorts tests by trade-off, and the table is the reason mocking strategy differs per layer:
| Trade-off | Unit | Widget | Integration |
|---|---|---|---|
| Confidence | Low | Higher | Highest |
| Maintenance cost | Low | Higher | Highest |
| Dependencies | Few | More | Most |
| Execution speed | Quick | Quick | Slow |
Unit tests check one function, method, or class and mock their external dependencies; they do not read or write to disk, render to screen, or receive outside user action. Widget tests need a real widget lifecycle but a simplified environment. Integration tests run the whole app on a device or emulator. You mock aggressively in the first row and barely at all in the third.
Fakes versus mocks
A mock is an object you generate or write that records calls and returns canned answers configured per test (when(...).thenAnswer(...)). A fake is a working alternative implementation you write by hand, usually with in-memory state.
The Flutter case study draws the line by dependency type: view model and view tests fake the repositories, while odd-shaped collaborators get mocks. In their widget tests, FakeBookingRepository accumulates real state and GoRouter is a mocktail mock:
class FakeBookingRepository implements BookingRepository {
final bookings = <Booking>[];
@override
Future<Result<void>> createBooking(Booking booking) async {
bookings.add(booking);
return Result.ok(null);
}
// ... other members
}
// Unit test, no Flutter libraries needed:
test('Load bookings', () {
final viewModel = HomeViewModel(
bookingRepository: FakeBookingRepository()..createBooking(kBooking),
userRepository: FakeUserRepository(),
);
expect(viewModel.bookings.isNotEmpty, true);
});
Prefer a fake when the test cares about state over a sequence (load, then add, then verify the list) because you can write natural scenarios instead of an ever-growing when ladder. Prefer a mock when you care about call verification or when the collaborator is awkward to fake, such as a router, a clock, or a one-shot HTTP client.
The mockito codegen workflow
Dart’s null safety means mockito generates its mocks. The Flutter cookbook’s mocking recipe has five steps, and the code below is the shape you end up with.
flutter pub add http dev:mockito dev:build_runner
Change the code under test to receive its collaborators instead of constructing them. The cookbook switches http.get() (static, effectively unmockable) for an injected client:
Future<Album> fetchAlbum(http.Client client) async {
final response = await client.get(
Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
);
if (response.statusCode == 200) {
return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
throw Exception('Failed to load album');
}
Annotate main in the test file and generate:
@GenerateMocks([], customMocks: [MockSpec<http.Client>(as: #MockHttpClient)])
void main() {}
dart run build_runner build
Then write one test per condition. The generated MockHttpClient lands in fetch_album_test.mocks.dart:
import 'fetch_album_test.mocks.dart';
void main() {
group('fetchAlbum', () {
test('returns an Album if the http call completes successfully', () async {
final client = MockHttpClient();
when(client.get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
.thenAnswer((_) async =>
http.Response('{"userId": 1, "id": 2, "title": "mock"}', 200));
expect(await fetchAlbum(client), isA<Album>());
});
test('throws an exception if the http call completes with an error', () {
final client = MockHttpClient();
when(client.get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1')))
.thenAnswer((_) async => http.Response('Not Found', 404));
expect(fetchAlbum(client), throwsException);
});
});
}
flutter test test/fetch_album_test.dart
The reasons the recipe gives for mocking apply to everything below: live services slow tests down, make passing tests fail when a server misbehaves (flaky tests), and make success and failure scenarios hard to reproduce.
Injecting doubles through Provider and Riverpod
In production the widget tree is rooted in MultiProvider; in a widget test you rebuild a smaller tree with the fakes already in it. The case study wraps the screen under test in .value providers so nothing is constructed twice:
void loadWidget(WidgetTester tester) async {
await testApp(
tester,
ChangeNotifierProvider.value(
value: FakeAuthRepository() as AuthRepository,
child: Provider.value(
value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
child: HomeScreen(viewModel: viewModel),
),
),
goRouter: goRouter,
);
}
Casting to the interface type matters: the widget asks for AuthRepository, so the fake must be registered as that type, not as FakeAuthRepository.
Riverpod’s equivalent is the overrides parameter, available on both ProviderContainer and ProviderScope. Every provider can be mocked with no extra setup:
// Unit test
final container = ProviderContainer.test(
overrides: [
exampleProvider.overrideWith((ref) => 'Hello from tests'),
],
);
// Widget test
await tester.pumpWidget(
ProviderScope(
overrides: [
exampleProvider.overrideWith((ref) => 'Hello from tests'),
],
child: const YourWidgetYouWantToTest(),
),
);
Two Riverpod details save you from subtle bugs. Do not share containers between tests; ProviderContainer.test() builds a fresh one, so state never leaks from the previous test. And when reading an auto-disposed provider, use container.listen rather than container.read, otherwise the state can be destroyed mid-test. For assertions on interactions you can container.listen(provider, (prev, next) { ... }) and collect changes, or combine it with mockito/mocktail’s verify.
What to mock, what to run real
- Mock or fake: network clients, databases, file storage, shared preferences, platform channels, clocks, routers, and anything with side effects. Unit tests by definition do not touch disk, screen, or outside processes.
- Run real: your own pure logic, models, serialization code, and the widget subtree under test. A widget test exercises the real layout, gesture, and build pipeline with only its dependencies faked.
- Do not mock the thing you are testing. Riverpod puts it bluntly: mocking a
Notifieris discouraged because notifiers cannot be instantiated outside a provider. Introduce an abstraction below it and mock that instead, for example the repository the notifier calls. - Avoid over-mocking. If every test re-implements the collaborator’s behavior in
whenblocks, your mock becomes a second implementation that can drift from the real one, and a rename turns into dozens of generated-file regenerations. Fakes centralize that behavior once. Also, rememberwhenonly stubs what you configure: a call you did not stub on a strict mock fails loudly, which is useful for assertions but produces brittle tests if you stub incidental calls.
A worked widget test
Suppose ProfileScreen takes a view model that takes a repository. Build the tree with fakes injected, pump it, and assert with finders and matchers:
testWidgets('shows the greeting from the repository', (tester) async {
final repository = FakeUserRepository()..seed(name: 'Ada');
final viewModel = UserViewModel(userRepository: repository);
await tester.pumpWidget(
Provider.value(
value: viewModel,
child: const MaterialApp(home: ProfileScreen()),
),
);
expect(find.text('Ada'), findsOneWidget);
await tester.tap(find.byType(IconButton));
await tester.pumpAndSettle();
expect(find.text('Ada Lovelace'), findsOneWidget);
});
Notes on the mechanics, straight from the widget testing recipe: testWidgets creates a fresh WidgetTester per case; pumpWidget builds and renders the tree; after anything that calls setState you must schedule frames yourself with tester.pump() or tester.pumpAndSettle(), the latter looping until no frames are scheduled, which is what you want after an animation. Then verify with matchers: findsOneWidget, findsNothing, findsNWidgets, and matchesGoldenFile for rendered pixels.
Key Takeaways
- Inject dependencies through constructors and keep them in private fields; that single habit is what makes every layer fake-able.
- Use fakes for repositories and stateful collaborators, mocks for call verification and awkward one-offs like routers and HTTP clients.
- mockito needs codegen: annotate with
@GenerateMocks, rundart run build_runner build, then stub withwhen(...).thenAnswer(...)in each test. - Swap doubles at the root:
Provider.value/ChangeNotifierProvider.valuein a widget test,ProviderScope(overrides: ...)orProviderContainer.test(overrides: ...)in Riverpod. - Mock the layers below you, not the object under test, and not everything: pure logic and the widget under test should run real.
- Match the layer to the payoff: lots of fast unit and widget tests, a few integration tests over the paths that matter.
References:
- https://docs.flutter.dev/testing/overview
- https://docs.flutter.dev/cookbook/testing/unit/mocking
- https://docs.flutter.dev/app-architecture/case-study/dependency-injection
- https://docs.flutter.dev/app-architecture/case-study/testing
- https://docs.flutter.dev/cookbook/testing/widget/introduction
- https://riverpod.dev/docs/how_to/testing