Mocking in Dart with Mockito and Mocktail

Mocking in Dart with Mockito and Mocktail

Mocking is the backbone of isolated unit testing. When your UserBloc depends on a UserRepository that hits a network, you don't want real HTTP calls in tests — you want a fake that returns exactly what you tell it to. Dart has two dominant mocking libraries: mockito (the established choice with code generation) and mocktail (the newer, codegen-free alternative). This post covers both comprehensively, including argument matchers, stream mocking, and when to choose each.

The Problem Mocks Solve

Consider a UserBloc that loads a user from a repository:

class UserBloc {
  final UserRepository repository;
  UserBloc(this.repository);

  Future<User?> loadUser(String id) async {
    try {
      return await repository.getUser(id);
    } catch (e) {
      return null;
    }
  }
}

Without mocking, testing loadUser requires a real UserRepository that makes HTTP calls. That means your test is slow, depends on network availability, and cannot easily test error paths. Mocks solve this by replacing UserRepository with a controlled fake.

Mockito: Code Generation Approach

Mockito for Dart uses build_runner to generate mock classes at compile time. This provides type safety and IDE support, but requires a code generation step.

Setup

# pubspec.yaml
dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0
  flutter_test:
    sdk: flutter

Generating Mocks

Annotate your test file with @GenerateMocks:

// test/user_bloc_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/repositories/user_repository.dart';
import 'package:my_app/blocs/user_bloc.dart';
import 'package:flutter_test/flutter_test.dart';

import 'user_bloc_test.mocks.dart'; // Generated file

@GenerateMocks([UserRepository])
void main() {
  // Tests here
}

Run code generation:

dart run build_runner build --delete-conflicting-outputs

This generates user_bloc_test.mocks.dart containing a MockUserRepository class.

Basic Stubbing with when/thenReturn

@GenerateMocks([UserRepository])
void main() {
  late MockUserRepository mockRepo;
  late UserBloc bloc;

  setUp(() {
    mockRepo = MockUserRepository();
    bloc = UserBloc(mockRepo);
  });

  test('returns user when repository succeeds', () async {
    final expectedUser = User(id: '1', name: 'Alice');

    when(mockRepo.getUser('1')).thenReturn(expectedUser);
    // For async methods, use thenAnswer:
    when(mockRepo.getUser('1')).thenAnswer((_) async => expectedUser);

    final result = await bloc.loadUser('1');

    expect(result, equals(expectedUser));
  });

  test('returns null when repository throws', () async {
    when(mockRepo.getUser(any)).thenThrow(Exception('Network error'));

    final result = await bloc.loadUser('1');

    expect(result, isNull);
  });
}

thenReturn vs thenAnswer:

  • thenReturn(value) — returns the same value every time. Use for synchronous methods or futures that have already completed.
  • thenAnswer((invocation) => ...) — called fresh on each invocation. Required for async methods (otherwise Dart reuses the same completed Future across calls, which causes subtle bugs).

Argument Matchers

Real methods often take complex arguments. Mockito provides matchers for flexible stubbing:

// Match any argument
when(mockRepo.getUser(any)).thenAnswer((_) async => defaultUser);

// Match specific value
when(mockRepo.getUser('admin')).thenAnswer((_) async => adminUser);

// Match with predicate
when(mockRepo.searchUsers(argThat(startsWith('Al'))))
  .thenAnswer((_) async => [aliceUser, alfredUser]);

// Match any named argument
when(mockRepo.fetchPage(
  page: anyNamed('page'),
  limit: anyNamed('limit'),
)).thenAnswer((_) async => PagedResult(items: []));

// Match specific named argument
when(mockRepo.fetchPage(
  page: 1,
  limit: anyNamed('limit'),
)).thenAnswer((_) async => firstPage);

Accessing invocation arguments inside thenAnswer:

when(mockRepo.getUser(any)).thenAnswer((invocation) async {
  final id = invocation.positionalArguments[0] as String;
  return User(id: id, name: 'User $id');
});

verify: Asserting Interactions

verify checks that a method was called with specific arguments:

test('saves user after successful load', () async {
  when(mockRepo.getUser('1')).thenAnswer((_) async => user);
  when(mockRepo.saveToCache(any)).thenAnswer((_) async {});

  await bloc.loadAndCacheUser('1');

  // Verify called once with the right argument
  verify(mockRepo.saveToCache(user)).called(1);

  // Verify called at least once
  verify(mockRepo.getUser(any)).called(greaterThanOrEqualTo(1));

  // Verify never called
  verifyNever(mockRepo.deleteUser(any));
});

test('verifies call order', () async {
  when(mockRepo.getUser(any)).thenAnswer((_) async => user);
  when(mockRepo.logAccess(any)).thenAnswer((_) async {});

  await bloc.loadAndLogUser('1');

  verifyInOrder([
    mockRepo.getUser('1'),
    mockRepo.logAccess('1'),
  ]);
});

Mocking Streams with Mockito

abstract class EventRepository {
  Stream<Event> watchEvents(String userId);
}

@GenerateMocks([EventRepository])
void main() {
  test('bloc emits events from stream', () async {
    final mockRepo = MockEventRepository();
    final controller = StreamController<Event>();

    when(mockRepo.watchEvents('user1')).thenAnswer((_) => controller.stream);

    final bloc = EventBloc(mockRepo);
    bloc.watchUser('user1');

    controller.add(Event(type: 'login'));
    controller.add(Event(type: 'purchase'));

    await expectLater(
      bloc.events,
      emitsInOrder([
        isA<Event>().having((e) => e.type, 'type', 'login'),
        isA<Event>().having((e) => e.type, 'type', 'purchase'),
      ]),
    );

    await controller.close();
  });
}

Mocktail: No Code Generation

mocktail takes a different approach: it uses Dart's noSuchMethod at runtime, eliminating the build step entirely. The tradeoff is that you must manually register fallback values for custom types.

Setup

dev_dependencies:
  mocktail: ^1.0.0
  flutter_test:
    sdk: flutter

No build_runner needed.

Defining Mocks

import 'package:mocktail/mocktail.dart';

class MockUserRepository extends Mock implements UserRepository {}
class MockAnalyticsService extends Mock implements AnalyticsService {}

That's it. No annotations. No generated files.

Stubbing with when/thenReturn

The API is deliberately similar to Mockito:

void main() {
  late MockUserRepository mockRepo;
  late UserBloc bloc;

  setUpAll(() {
    // Register fallback values for custom types used in argument matchers
    registerFallbackValue(User(id: '', name: ''));
  });

  setUp(() {
    mockRepo = MockUserRepository();
    bloc = UserBloc(mockRepo);
  });

  test('returns user on success', () async {
    final user = User(id: '1', name: 'Alice');

    when(() => mockRepo.getUser('1')).thenAnswer((_) async => user);

    final result = await bloc.loadUser('1');

    expect(result, equals(user));
  });

  test('handles errors', () async {
    when(() => mockRepo.getUser(any())).thenThrow(Exception('timeout'));

    final result = await bloc.loadUser('1');

    expect(result, isNull);
  });
}

Notice: Mocktail requires the lambda syntax () => mockRepo.method() inside when. This is how it captures the call without actually executing it.

Argument Matchers in Mocktail

// Match anything
when(() => mockRepo.getUser(any())).thenAnswer((_) async => defaultUser);

// Match with predicate
when(() => mockRepo.searchUsers(
  any(that: startsWith('Al')),
)).thenAnswer((_) async => []);

// Named arguments
when(() => mockRepo.fetchPage(
  page: any(named: 'page'),
  limit: any(named: 'limit'),
)).thenAnswer((_) async => PagedResult(items: []));

verify in Mocktail

test('calls analytics on error', () async {
  when(() => mockRepo.getUser(any())).thenThrow(Exception());
  when(() => mockAnalytics.trackError(any())).thenReturn(null);

  await bloc.loadUser('1');

  verify(() => mockAnalytics.trackError(any())).called(1);
  verifyNever(() => mockAnalytics.trackSuccess(any()));
});

// Capture arguments for detailed assertions
test('passes correct user to analytics', () async {
  final user = User(id: '42', name: 'Bob');
  when(() => mockRepo.getUser('42')).thenAnswer((_) async => user);
  when(() => mockAnalytics.trackUserLoad(captureAny())).thenReturn(null);

  await bloc.loadUser('42');

  final captured = verify(() => mockAnalytics.trackUserLoad(captureAny()))
    .captured;
  expect(captured.first, equals(user));
});

Mocking Streams with Mocktail

test('streams events correctly', () async {
  final mockRepo = MockEventRepository();
  final controller = StreamController<Event>();

  when(() => mockRepo.watchEvents(any())).thenAnswer((_) => controller.stream);

  final received = <Event>[];
  final bloc = EventBloc(mockRepo);
  final subscription = bloc.events.listen(received.add);

  controller.add(Event(type: 'click'));
  await Future.microtask(() {}); // Let the stream propagate

  expect(received, hasLength(1));
  expect(received.first.type, equals('click'));

  await controller.close();
  await subscription.cancel();
});

Registering Fallback Values

This is the most common Mocktail gotcha. When you use any() with a custom type, Mocktail needs a "fallback value" to return if the matcher is used in a verify call before the mock has been called. Register them in setUpAll:

setUpAll(() {
  registerFallbackValue(User(id: '', name: ''));
  registerFallbackValue(const Duration(seconds: 0));
  registerFallbackValue(Uri.parse('https://example.com'));
});

Primitive types (String, int, bool, double) don't need registration. Custom classes and non-nullable types from packages do.

Mockito vs Mocktail: Choosing the Right Tool

Factor Mockito Mocktail
Code generation Required Not required
CI build step Yes (build_runner) No
Type safety Compile-time Runtime
Null safety Full support Full support
API style when(mock.method()) when(() => mock.method())
Fallback values Not needed Required for custom types
Matchers any, argThat any(), any(that:)
Capture captureAny captureAny()

Choose Mockito when:

  • Your team is large and compile-time safety catches more bugs.
  • You already have a build_runner workflow (e.g., for JSON serialization).
  • Generated mock files being committed to git is acceptable.

Choose Mocktail when:

  • You want zero build steps in your test workflow.
  • You're prototyping or working in a small team.
  • You're writing tests alongside feature code and want fast iteration.

Advanced Pattern: Mocking with Fake Implementations

Sometimes a mock isn't expressive enough. For complex behavior, implement a Fake:

// With Mocktail
class FakeUserRepository extends Fake implements UserRepository {
  final Map<String, User> _store = {};

  @override
  Future<User?> getUser(String id) async => _store[id];

  @override
  Future<void> saveUser(User user) async => _store[user.id] = user;
}

void main() {
  test('bloc reads user saved by another call', () async {
    final fakeRepo = FakeUserRepository();
    final bloc = UserBloc(fakeRepo);

    await bloc.createUser(User(id: '1', name: 'Alice'));
    final result = await bloc.loadUser('1');

    expect(result?.name, equals('Alice'));
  });
}

Fakes are ideal for stateful repositories where the interaction between multiple calls matters.

Common Mistakes to Avoid

Using thenReturn for async methods (Mockito):

// WRONG — reuses the same completed Future
when(mockRepo.getUser('1')).thenReturn(Future.value(user));

// CORRECT — creates a fresh Future each call
when(mockRepo.getUser('1')).thenAnswer((_) async => user);

Forgetting the lambda in Mocktail:

// WRONG — actually calls the method
when(mockRepo.getUser('1')).thenAnswer((_) async => user);

// CORRECT
when(() => mockRepo.getUser('1')).thenAnswer((_) async => user);

Not resetting mocks between tests:

setUp(() {
  mockRepo = MockUserRepository(); // Always create fresh — don't reuse
});

Verifying too much:

Only verify interactions that are part of the test's concern. Over-verification makes tests brittle and fails when unrelated code paths change.

Conclusion

Mockito and Mocktail both deliver reliable, readable mocks for Dart. Mockito's code generation gives you type safety at compile time — useful in large codebases where a renamed method should break all its mocks immediately. Mocktail removes the build step entirely, trading runtime errors for development speed. The core stubbing patterns (when/thenAnswer, verify, argument matchers, stream controllers) are nearly identical between the two. Master either one and switching to the other takes an afternoon.

Read more

Start now free