Mocking in Dart: Mockito and Mocktail, @GenerateMocks, when/verify Patterns

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.0

Annotate 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 watch

This 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.4

Create 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.

Read more

Start now free