Mocking in Dart: Mockito and Mocktail, @GenerateMocks, when/verify Patterns
Mocking lets you replace real dependencies — HTTP clients, databases, sensors — with controlled fakes in tests. Dart has two excellent mocking libraries: Mockito (code-generation-based, type-safe) and Mocktail (no code gen, reflection-based). This guide covers both, with production-ready patterns for when/thenReturn, verify, argument matchers, and stubbing async code.
Key Takeaways
- Mockito requires @GenerateMocks + build_runner for type-safe generated mocks
- Mocktail needs no code generation — import and go
- Use when/thenReturn to stub return values and thenThrow for errors
- Use verify to assert interactions after the fact
- Argument matchers like any() and captureAny() make assertions flexible
A unit test that depends on a real HTTP client is not a unit test — it is a slow, flaky, network-dependent integration test. Mocking is the technique that breaks those dependencies. Replace the real ApiClient with a mock that returns controlled responses, and your tests become fast, deterministic, and runnable without network access.
Dart has two excellent mocking libraries: Mockito and Mocktail. Both serve the same purpose with different tradeoffs. This guide covers both in depth.
Mockito vs Mocktail at a Glance
| Feature | Mockito | Mocktail |
|---|---|---|
| Code generation | Required (build_runner) |
Not needed |
| Type safety | Full — generated mocks are real types | Relies on registerFallbackValue for some types |
| Setup overhead | Higher (annotations + build step) | Minimal |
| Null safety | Full support | Full support |
| Active maintenance | Yes (Google/Dart team) | Yes (Felix Angelov) |
Use Mockito when: you want full type safety and your team already uses build_runner. Use Mocktail when: you want zero setup overhead and fast iteration.
Mockito: Setup
Add to pubspec.yaml:
dev_dependencies:
mockito: ^5.4.4
build_runner: ^2.4.0Annotate Your Test with @GenerateMocks
// test/services/user_service_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/services/api_client.dart';
import 'package:my_app/services/user_service.dart';
import 'package:flutter_test/flutter_test.dart';
// Generate mocks for these classes
@GenerateMocks([ApiClient])
import 'user_service_test.mocks.dart'; // generated file
void main() {
// ...
}Run code generation:
dart run build_runner build
# or watch mode during development
dart run build_runner watchThis generates user_service_test.mocks.dart with a MockApiClient class.
Basic Stubbing with when/thenReturn
void main() {
late MockApiClient mockApiClient;
late UserService userService;
setUp(() {
mockApiClient = MockApiClient();
userService = UserService(apiClient: mockApiClient);
});
test('getUser returns User on success', () async {
// Arrange — stub the mock
when(mockApiClient.getUser('user-123'))
.thenReturn(Future.value(User(id: 'user-123', name: 'Alice')));
// Act
final user = await userService.getUser('user-123');
// Assert
expect(user.name, equals('Alice'));
expect(user.id, equals('user-123'));
});
test('getUser throws on 404', () async {
// Stub to throw an exception
when(mockApiClient.getUser('unknown'))
.thenThrow(ApiException(statusCode: 404, message: 'Not found'));
// Assert the exception is propagated
expect(
() => userService.getUser('unknown'),
throwsA(isA<UserNotFoundException>()),
);
});
}Stubbing Async Methods
// thenReturn wraps in a completed Future
when(mockApiClient.fetchProducts())
.thenReturn(Future.value([
Product(id: '1', name: 'Widget'),
Product(id: '2', name: 'Gadget'),
]));
// thenAnswer for more control (called fresh each time)
when(mockApiClient.fetchProducts()).thenAnswer(
(_) async => [
Product(id: '1', name: 'Widget'),
],
);
// thenThrow for async errors
when(mockApiClient.fetchProducts())
.thenThrow(NetworkException('Connection refused'));
// Or return a failed Future
when(mockApiClient.fetchProducts()).thenAnswer(
(_) => Future.error(NetworkException('Timeout')),
);verify: Assert Interactions
verify checks that a mock method was called with specific arguments. Use it after the act phase:
test('saves user after successful fetch', () async {
when(mockApiClient.getUser(any)).thenReturn(
Future.value(User(id: '1', name: 'Alice')),
);
when(mockDatabase.saveUser(any)).thenReturn(Future.value());
await userService.fetchAndSaveUser('1');
// Verify the API was called once with the correct ID
verify(mockApiClient.getUser('1')).called(1);
// Verify the database was also called
verify(mockDatabase.saveUser(any)).called(1);
});
// Verify never called
verifyNever(mockApiClient.deleteUser(any));
// Verify called at least once
verify(mockApiClient.getUser(any)).called(greaterThanOrEqualTo(1));Argument Matchers
// any() matches any argument
when(mockApiClient.getUser(any))
.thenReturn(Future.value(User(id: '1', name: 'Alice')));
// anyNamed for named parameters
when(mockApiClient.search(query: anyNamed('query')))
.thenReturn(Future.value([]));
// argThat for custom matching
when(mockApiClient.createUser(
argThat(isA<User>().having((u) => u.email, 'email', contains('@'))),
)).thenReturn(Future.value('new-id'));
// captureAny to capture and inspect arguments later
final captured = verify(mockApiClient.createUser(captureAny)).captured;
expect(captured.single.name, equals('Bob'));Generating Mocks for Multiple Classes
@GenerateMocks([
ApiClient,
UserRepository,
AuthService,
LocalDatabase,
])Run build_runner build once and all mocks are generated.
Mocktail: Setup
No code generation. Just add the package:
dev_dependencies:
mocktail: ^1.0.4Create mock classes manually — they are simple:
import 'package:mocktail/mocktail.dart';
import 'package:my_app/services/api_client.dart';
class MockApiClient extends Mock implements ApiClient {}
class MockUserRepository extends Mock implements UserRepository {}That is the entire setup. No annotations, no build_runner.
Basic Stubbing with Mocktail
The API is nearly identical to Mockito:
void main() {
late MockApiClient mockApiClient;
late UserService userService;
setUpAll(() {
// Register fallback values for non-nullable custom types
// Required when using any() with custom types
registerFallbackValue(User(id: '', name: ''));
});
setUp(() {
mockApiClient = MockApiClient();
userService = UserService(apiClient: mockApiClient);
});
test('getUser returns user on success', () async {
when(() => mockApiClient.getUser('user-123'))
.thenAnswer((_) async => User(id: 'user-123', name: 'Alice'));
final user = await userService.getUser('user-123');
expect(user.name, equals('Alice'));
});
test('getUser throws UserNotFound on 404', () async {
when(() => mockApiClient.getUser('unknown'))
.thenThrow(ApiException(statusCode: 404));
expect(
() => userService.getUser('unknown'),
throwsA(isA<UserNotFoundException>()),
);
});
}The main difference: when(() => ...) — Mocktail uses a closure to capture the call.
Stubbing in Mocktail
// Sync return value
when(() => mockRepo.getCount()).thenReturn(42);
// Async return value
when(() => mockApiClient.fetchUser(any())).thenAnswer(
(_) async => User(id: '1', name: 'Alice'),
);
// Return different values on successive calls
var callCount = 0;
when(() => mockApiClient.fetchToken()).thenAnswer((_) async {
callCount++;
if (callCount == 1) return 'token-v1';
return 'token-v2';
});
// Throw
when(() => mockApiClient.fetchUser('bad')).thenThrow(Exception('not found'));
// Answer with access to invocation details
when(() => mockApiClient.fetchUser(any())).thenAnswer((invocation) async {
final id = invocation.positionalArguments[0] as String;
return User(id: id, name: 'User $id');
});verify in Mocktail
// Called exactly once
verify(() => mockApiClient.saveUser(any())).called(1);
// Called a specific number of times
verify(() => mockApiClient.sendAnalytics(any())).called(3);
// Never called
verifyNever(() => mockApiClient.deleteUser(any()));
// Any call matching a predicate
verify(
() => mockApiClient.createUser(
any(that: isA<User>().having((u) => u.name, 'name', isNotEmpty)),
),
).called(1);Capturing Arguments in Mocktail
final captured = verify(
() => mockRepo.saveUser(captureAny()),
).captured;
final savedUser = captured.single as User;
expect(savedUser.name, equals('Alice'));
expect(savedUser.email, contains('@'));Testing a Complete Service with Mockito
Here is a realistic example: a ProductService that depends on an API client and a cache:
// lib/services/product_service.dart
class ProductService {
final ApiClient _api;
final CacheClient _cache;
ProductService({required ApiClient api, required CacheClient cache})
: _api = api,
_cache = cache;
Future<List<Product>> getProducts({bool forceRefresh = false}) async {
if (!forceRefresh) {
final cached = await _cache.get<List<Product>>('products');
if (cached != null) return cached;
}
final products = await _api.fetchProducts();
await _cache.set('products', products, ttl: const Duration(minutes: 5));
return products;
}
Future<Product> getProduct(String id) async {
final cached = await _cache.get<Product>('product:$id');
if (cached != null) return cached;
return _api.fetchProduct(id);
}
}Test with Mockito:
@GenerateMocks([ApiClient, CacheClient])
import 'product_service_test.mocks.dart';
void main() {
late MockApiClient mockApi;
late MockCacheClient mockCache;
late ProductService service;
final testProducts = [
Product(id: '1', name: 'Widget', price: 9.99),
Product(id: '2', name: 'Gadget', price: 29.99),
];
setUp(() {
mockApi = MockApiClient();
mockCache = MockCacheClient();
service = ProductService(api: mockApi, cache: mockCache);
});
group('getProducts', () {
test('returns cached products when cache is populated', () async {
when(mockCache.get<List<Product>>('products'))
.thenReturn(Future.value(testProducts));
final products = await service.getProducts();
expect(products, equals(testProducts));
verifyNever(mockApi.fetchProducts());
});
test('fetches from API and caches when cache is empty', () async {
when(mockCache.get<List<Product>>('products'))
.thenReturn(Future.value(null));
when(mockApi.fetchProducts())
.thenReturn(Future.value(testProducts));
when(mockCache.set(any, any, ttl: anyNamed('ttl')))
.thenReturn(Future.value());
final products = await service.getProducts();
expect(products, equals(testProducts));
verify(mockApi.fetchProducts()).called(1);
verify(mockCache.set('products', testProducts, ttl: anyNamed('ttl')))
.called(1);
});
test('bypasses cache when forceRefresh is true', () async {
when(mockApi.fetchProducts())
.thenReturn(Future.value(testProducts));
when(mockCache.set(any, any, ttl: anyNamed('ttl')))
.thenReturn(Future.value());
await service.getProducts(forceRefresh: true);
verifyNever(mockCache.get<List<Product>>(any));
verify(mockApi.fetchProducts()).called(1);
});
});
group('getProduct', () {
test('returns product from API when not cached', () async {
final product = testProducts.first;
when(mockCache.get<Product>('product:1'))
.thenReturn(Future.value(null));
when(mockApi.fetchProduct('1'))
.thenReturn(Future.value(product));
final result = await service.getProduct('1');
expect(result.id, equals('1'));
expect(result.name, equals('Widget'));
});
});
}Fake Classes vs Mocks
For simple dependencies, a hand-written fake is often cleaner than a mock:
// A fake in-memory repository — no mocking library needed
class FakeUserRepository implements UserRepository {
final Map<String, User> _users = {};
@override
Future<User?> findById(String id) async => _users[id];
@override
Future<void> save(User user) async => _users[user.id] = user;
@override
Future<void> delete(String id) async => _users.remove(id);
// Test helper — not in the interface
void seed(List<User> users) {
for (final u in users) {
_users[u.id] = u;
}
}
}Use fakes when:
- The dependency has multiple methods you always need to stub the same way
- You want to verify complex state rather than just interaction counts
- The fake's behavior is straightforward enough to be readable in the test file
Use mocks when:
- You need to verify exact call counts or argument values
- Different tests need different behavior from the same method
- You want to test error handling paths without implementing them in a fake
Mocking Streams
// Mockito
when(mockSensor.readingsStream).thenAnswer(
(_) => Stream.fromIterable([
SensorReading(value: 1.0, timestamp: DateTime.now()),
SensorReading(value: 2.0, timestamp: DateTime.now()),
]),
);
// Mocktail
when(() => mockSensor.readingsStream).thenAnswer(
(_) => Stream.fromIterable([
SensorReading(value: 1.0, timestamp: DateTime.now()),
]),
);
// StreamController for more control
final controller = StreamController<SensorReading>();
when(() => mockSensor.readingsStream).thenAnswer(
(_) => controller.stream,
);
// In test: emit values on demand
controller.add(SensorReading(value: 42.0, timestamp: DateTime.now()));
await tester.pump(); // or just await Future.microtask(() {})Best Practices
1. Mock at the boundary, not deep inside Mock your repositories and HTTP clients, not internal helper classes. If you are mocking 6 levels deep, your architecture needs attention.
2. Keep stubs close to the test that uses them Avoid giant setUp blocks with 20 stubs. Put each stub in the test that needs it, or in a narrow setUp scoped to a group.
3. Do not over-verify Only verify interactions that are part of the behavior you are testing. Verifying every single mock call makes tests brittle and hard to read.
4. Prefer thenAnswer over thenReturn for async methods thenReturn(Future.value(x)) returns the same completed future every time. thenAnswer((_) async => x) creates a fresh future per call — important if the SUT calls the method more than once.
5. Test the error path Every stubbed happy path should have a corresponding error case test. Use thenThrow and thenAnswer((_) => Future.error(...)).
Scale Your Mocking Strategy with AI
Mocking is mechanical work. Given your interfaces and service classes, the stubs and verify calls follow predictable patterns. AI tooling can generate first-draft mock setups and identify missing test cases.
HelpMeTest analyzes your Dart codebase and suggests tests for uncovered service methods, including the stub setup and edge case scenarios. With usage-based pricing and no base fee, it integrates with your CI pipeline and flags services with zero test coverage before PRs merge. Stop writing boilerplate — focus on the cases that actually matter.