Mocking & Testability
Writing testable Flutter code requires adhering to SOLID design principles, particularly the Dependency Inversion Principle (DIP). By injecting abstract dependencies into classes rather than instantiating concrete implementations inline, developers can substitute real network and database calls with Mocks, Fakes, and Stubs using mocktail or mockito.
1. Mocks vs. Fakes vs. Stubs vs. Spies
| Test Double | Purpose | Behavior | Example |
|---|---|---|---|
| Stub | Pre-programmed Response | Returns fixed data when called | when(() => repo.getUser()).thenReturn(user) |
| Mock | Interaction Verification | Verifies if methods were called with specific args | verify(() => repo.getUser()).called(1) |
| Fake | Working In-Memory Implementation | Implements interface with simplified logic | FakeUserRepository using an in-memory List |
| Spy | Wraps Real Instance | Delegates calls to real object while recording calls | spy(realService) |
2. Mocking Frameworks: mocktail vs mockito
1. mocktail (No Code Generation)
Uses Dart features (noSuchMethod) to provide type-safe mocking without running build_runner.
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late MockUserRepository mockRepo;
late GetUserUseCase useCase;
setUp(() {
mockRepo = MockUserRepository();
useCase = GetUserUseCase(mockRepo);
});
test('returns user when repository call succeeds', () async {
// 1. Stub response
when(() => mockRepo.getUser('123'))
.thenAnswer((_) async => const User(id: '123', name: 'Alice'));
// 2. Execute
final result = await useCase.execute('123');
// 3. Assert & Verify
expect(result.name, 'Alice');
verify(() => mockRepo.getUser('123')).called(1);
});
}2. mockito (Code Generation Based)
Requires @GenerateMocks([UserRepository]) and running flutter pub run build_runner build.
- Pros: Strict null-safety guarantees generated at build time.
- Cons: Slower developer feedback loop due to code generation steps.
3. Mocking Streams & Async Callbacks
When testing reactive BLoCs or Riverpod providers that listen to continuous data streams:
// Mocking a Stream emission sequence
when(() => mockAuthService.authStateStream)
.thenAnswer((_) => Stream.fromIterable([
AuthState.unauthenticated(),
AuthState.authenticated(user),
]));4. Injecting Mocks in Widget Tests
Overriding Riverpod Providers in Widget Tests
testWidgets('Renders user profile when provider yields data', (tester) async {
final mockUser = User(id: '1', name: 'Alice');
await tester.pumpWidget(
ProviderScope(
overrides: [
// Override real network provider with mock/fake value!
userProvider.overrideWith((ref) => Future.value(mockUser)),
],
child: const MaterialApp(home: ProfileScreen()),
),
);
await tester.pump();
expect(find.text('Alice'), findsOneWidget);
});5. Trade-offs & Production Considerations
- Over-Mocking Fragility: Over-mocking internal implementation details creates fragile tests that break whenever internal code is refactored—even if external behavior remains unchanged. Mock ONLY boundary interfaces (Network, Storage, Platform Channels).
- Fallback Registration in
mocktail: When stubbing methods with custom object parameters inmocktail, register fallback values usingregisterFallbackValue(MyCustomType())insidesetUpAll().