Mocking #

Mocking is the technique of replacing real dependencies (databases, HTTP clients, filesystems) with fake implementations fully controllable from within the test. This enables fast, deterministic, isolated unit testing — no network connections, no real data, no side effects. Dart uses mockito as its main mocking library, which since version 5 uses code generation for full type safety. This article covers modern mockito usage, the differences between Mocks, Fakes, and Stubs, and when you should not use mocks.

Mock, Fake, Stub, and Spy — What’s the Difference? #

These four terms are often used interchangeably but have important differences:

TermDescriptionUsed for
MockA fake object that verifies interactions — which methods were called, how many times, with what argumentsBehavior verification (behavioral testing)
StubA fake object that only returns specific values without verifying callsProviding controlled data
FakeA real but simplified implementation (e.g. an in-memory database)When mocks are too verbose
SpyWraps a real object and records its interactionsVerification on real objects
// Stub — only return a value, no call verification
when(mockRepo.ambilById('P001')).thenReturn(produkFix);

// Mock — return a value AND verify the call
when(mockRepo.ambilById(any)).thenReturn(produkFix);
// ... code under test ...
verify(mockRepo.ambilById('P001')).called(1); // verify the interaction

// Fake — a simplified real implementation
class FakeProdukRepository implements ProdukRepository {
  final Map<String, Produk> _data = {};
  @override
  Future<Produk?> ambilById(String id) async => _data[id];
  @override
  Future<Produk> simpan(Produk p) async { _data[p.id] = p; return p; }
}

Modern Mockito Setup (Version 5+) #

Mockito 5+ uses annotation-based code generation — safer, more expressive, and fully compatible with null safety:

# pubspec.yaml
dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0
  test: ^1.25.0

Step 1: Annotate the Test File #

// test/layanan/produk_service_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';
import 'package:aplikasi_toko/src/domain/repository/produk_repository.dart';
import 'package:aplikasi_toko/src/domain/layanan/produk_service.dart';

// Annotation — generate mocks for all listed classes
@GenerateMocks([ProdukRepository])
// Or: @GenerateNiceMocks([MockSpec<ProdukRepository>()]) for nice mocks
void main() { /* ... */ }

Step 2: Generate the Mock Code #

# Generate the mock file (produces *_test.mocks.dart)
dart run build_runner build

# Or watch mode — auto-regenerates when files change
dart run build_runner watch

The produk_service_test.mocks.dart file will be created automatically and contain the MockProdukRepository class.

Step 3: Use the Mock in Tests #

// Import the generated file
import 'produk_service_test.mocks.dart';

void main() {
  group('ProdukService', () {
    late MockProdukRepository mockRepo;
    late ProdukService service;

    setUp(() {
      mockRepo = MockProdukRepository();
      service = ProdukService(mockRepo);
    });

    test('ambilProduk returns the found product', () async {
      // Arrange
      final produkDiharapkan = Produk(
        id: 'P001',
        nama: 'Laptop',
        harga: 15_000_000,
      );

      when(mockRepo.ambilById('P001'))
          .thenAnswer((_) async => produkDiharapkan);

      // Act
      final hasil = await service.ambilProduk('P001');

      // Assert
      expect(hasil?.nama, equals('Laptop'));
      expect(hasil?.harga, equals(15_000_000));
    });
  });
}

when — Configuring Mock Behavior #

// thenReturn — synchronous values
when(mock.metodeSinkron()).thenReturn('nilai');

// thenAnswer — asynchronous values or dependent on arguments
when(mock.metodAsync()).thenAnswer((_) async => 'async value');

// Using the invocation to access arguments
when(mock.cariProduk(any)).thenAnswer((invocation) async {
  final query = invocation.positionalArguments[0] as String;
  return query.isEmpty ? [] : [Produk(id: 'P1', nama: 'Hasil $query')];
});

// thenThrow — simulate an exception
when(mock.ambilById('TIDAK_ADA'))
    .thenThrow(NotFoundException('Product not found'));

// thenReturnInOrder — different values per call order
when(mock.metodePanggilan())
    .thenReturnInOrder(['pertama', 'kedua', 'ketiga']);

// Chained — first call returns a value, later calls throw
when(mock.metode())
    ..thenReturn('sukses')
    ..thenThrow(Exception('fails afterwards'));

Argument Matchers #

Argument matchers allow stubbing and verification without caring about specific argument values:

import 'package:mockito/mockito.dart';

// any — matches any argument of the inferred type
when(mock.cari(any)).thenReturn([]);
when(mock.buat(any, jumlah: anyNamed('jumlah'))).thenReturn(null);

// argThat — matches based on a custom condition
when(mock.cari(argThat(startsWith('dart'))))
    .thenReturn([produkDart]);

when(mock.simpan(argThat(isA<Produk>().having(
  (p) => p.harga,
  'harga',
  greaterThan(0),
)))).thenAnswer((_) async => true);

// Named argument matchers
when(mock.cariDenganFilter(
  nama: anyNamed('nama'),
  hargaMaks: anyNamed('hargaMaks'),
)).thenReturn([]);

// captureAny — capture argument values for verification
when(mock.kirimEmail(captureAny, captureAny))
    .thenAnswer((_) async {});
// ...after the call...
final captured = verify(mock.kirimEmail(captureAny, captureAny)).captured;
expect(captured[0], '[email protected]');  // first argument
expect(captured[1], contains('terima kasih'));  // second argument

verify — Verifying Interactions #

// Basic verification — the method was called once
verify(mock.ambilById('P001')).called(1);

// Called exactly N times
verify(mock.kirimLog(any)).called(3);

// Called at least N times
verify(mock.catat(any)).called(greaterThanOrEqualTo(1));

// Never called
verifyNever(mock.hapus(any));

// Verify no interaction at all
verifyNoMoreInteractions(mock);
verifyZeroInteractions(mock); // no method was called at all

// Verify call order
verifyInOrder([
  mock.mulaiTransaksi(),
  mock.simpanOrder(any),
  mock.kirimEmail(any, any),
  mock.selesaikanTransaksi(),
]);

captureThat — Capturing and Verifying Arguments #

// Capture arguments sent to the mock for deeper inspection
test('the service sends an email with the correct content', () async {
  when(mockEmail.kirim(any, captureAny))
      .thenAnswer((_) async {});

  await service.prosesPendaftaran(pengguna);

  final isiEmail = verify(mockEmail.kirim(any, captureAny))
      .captured.single as String;

  expect(isiEmail, contains('Welcome aboard'));
  expect(isiEmail, contains(pengguna.nama));
  expect(isiEmail, contains('Click the link below to verify'));
});

@GenerateNiceMocks — More Lenient Mocks #

Regular mocks (from @GenerateMocks) throw a MissingStubError if a method is called without a stub first. “Nice mocks” return default values (null, 0, false, []) without throwing:

// Regular mock — strict, throws if there's no stub
@GenerateMocks([ProdukRepository])

// Nice mock — lenient, returns defaults if there's no stub
@GenerateNiceMocks([MockSpec<ProdukRepository>()])

void main() {
  test('with a nice mock', () {
    final mock = MockProdukRepository();
    // No need to stub every method — unstubbed ones return null/defaults
    expect(mock.ambilById('P001'), completion(isNull)); // null without a stub
  });
}

Use regular mocks for tests that need precision — ensuring no method is called accidentally. Use nice mocks when only a small subset of methods is relevant to that test.


Fakes — a More Realistic Mock Alternative #

When mocks become too verbose (too many when stubs), consider a Fake — a simplified real implementation:

// Fake repository — in-memory, no real database needed
class FakeProdukRepository implements ProdukRepository {
  final Map<String, Produk> _store = {};
  bool hapusDipanggil = false;

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

  @override
  Future<List<Produk>> cariSemua() async => _store.values.toList();

  @override
  Future<Produk> simpan(Produk produk) async {
    _store[produk.id] = produk;
    return produk;
  }

  @override
  Future<void> hapus(String id) async {
    hapusDipanggil = true;
    _store.remove(id);
  }
}

// Usage — no stubs needed, the behavior is already defined
void main() {
  late FakeProdukRepository fakeRepo;
  late ProdukService service;

  setUp(() {
    fakeRepo = FakeProdukRepository();
    service = ProdukService(fakeRepo);
  });

  test('saves and fetches a product', () async {
    final produk = Produk(id: 'P001', nama: 'Laptop', harga: 15_000_000);

    await service.buatProduk(produk);
    final hasil = await service.ambilProduk('P001');

    expect(hasil?.nama, equals('Laptop'));
    // No verify needed — the Fake already stores real state
  });

  test('deleting a product removes it from the store', () async {
    fakeRepo._store['P001'] = Produk(id: 'P001', nama: 'Test', harga: 0);
    await service.hapusProduk('P001');

    expect(fakeRepo._store.containsKey('P001'), isFalse);
    expect(fakeRepo.hapusDipanggil, isTrue);
  });
}

Common Mocking Scenarios #

Mocking an HTTP Client #

@GenerateMocks([http.Client])
void main() {
  group('ProdukApiClient', () {
    late MockClient mockHttpClient;
    late ProdukApiClient client;

    setUp(() {
      mockHttpClient = MockClient();
      client = ProdukApiClient(httpClient: mockHttpClient);
    });

    test('GET /produk returns a list of products', () async {
      final responseJson = jsonEncode([
        {'id': 'P1', 'nama': 'Laptop', 'harga': 15000000},
      ]);

      when(mockHttpClient.get(
        Uri.parse('https://api.example.com/produk'),
        headers: anyNamed('headers'),
      )).thenAnswer((_) async => http.Response(responseJson, 200));

      final produk = await client.ambilSemuaProduk();

      expect(produk, hasLength(1));
      expect(produk.first.nama, equals('Laptop'));
    });

    test('handles an HTTP 404 correctly', () async {
      when(mockHttpClient.get(any, headers: anyNamed('headers')))
          .thenAnswer((_) async => http.Response('Not Found', 404));

      expect(
        () async => await client.ambilProduk('TIDAK_ADA'),
        throwsA(isA<NotFoundException>()),
      );
    });

    test('handles a network error', () async {
      when(mockHttpClient.get(any, headers: anyNamed('headers')))
          .thenThrow(SocketException('Network unavailable'));

      expect(
        () async => await client.ambilSemuaProduk(),
        throwsA(isA<NetworkException>()),
      );
    });
  });
}

Mocking with Multiple Dependencies #

@GenerateMocks([ProdukRepository, EmailService, LogService])
void main() {
  group('OrderService', () {
    late MockProdukRepository mockProdukRepo;
    late MockEmailService mockEmail;
    late MockLogService mockLog;
    late OrderService service;

    setUp(() {
      mockProdukRepo = MockProdukRepository();
      mockEmail = MockEmailService();
      mockLog = MockLogService();
      service = OrderService(
        produkRepo: mockProdukRepo,
        emailService: mockEmail,
        logService: mockLog,
      );
    });

    test('create order — full success', () async {
      // Arrange all dependencies
      when(mockProdukRepo.ambilById('P001'))
          .thenAnswer((_) async => Produk(id: 'P001', stok: 10, harga: 100));
      when(mockEmail.kirim(any, any)).thenAnswer((_) async {});
      when(mockLog.catat(any)).thenReturn(null);

      // Act
      final order = await service.buatOrder('P001', qty: 2, emailPembeli: '[email protected]');

      // Assert the result
      expect(order.total, equals(200));

      // Assert interactions — order matters!
      verifyInOrder([
        mockProdukRepo.ambilById('P001'),
        mockEmail.kirim('[email protected]', any),
        mockLog.catat(any),
      ]);
    });
  });
}

When Not to Use Mocks #

Over-mocking is an anti-pattern that makes tests fragile and hard to maintain. Some guidelines:

USE mocks when:
  ✓ Dependencies involve real I/O (HTTP, database, filesystem)
  ✓ Dependencies have side effects (sending emails, charging credit cards)
  ✓ Dependencies are slow (sleep, network timeouts)
  ✓ You want to control error scenarios that are hard to reproduce

DON'T mock when:
  ✗ Pure logic (math functions, data transformations) — test directly
  ✗ Simple value objects — create real instances
  ✗ Code that can be tested with a simpler Fake
  ✗ Every dependency reflexively — that's over-mocking
// ANTI-PATTERN: mocking pure logic that doesn't need a mock
test('calculate discount', () {
  final mockKalkulasi = MockKalkulasiDiskon();
  when(mockKalkulasi.hitung(100, 10)).thenReturn(10.0);
  // ✗ why mock? this function is deterministic with no I/O

  expect(mockKalkulasi.hitung(100, 10), 10.0);
});

// CORRECT: test directly without a mock
test('calculate discount', () {
  final kalkulasi = KalkulasiDiskon();
  expect(kalkulasi.hitung(harga: 100, persen: 10), equals(10.0));
});

Summary #

  • Mockito 5+ uses code generation — annotate with @GenerateMocks([KelasA, KelasB]) then run dart run build_runner build to produce a *_test.mocks.dart file with full type safety.
  • Mock vs Fake — use Mocks to verify interactions (which methods were called), Fakes for more realistic in-memory implementations that don’t need many stubs.
  • @GenerateNiceMocks produces lenient mocks (return null/defaults without stubs) — useful when only a subset of methods is relevant to the test.
  • Argument matchers (any, argThat, anyNamed) allow flexible stubs without caring about exact argument values — very useful for complex arguments.
  • captureAny and captureThat record the actual arguments sent to a mock — useful for verifying sent content (email bodies, API payloads).
  • verifyInOrder ensures methods are called in the correct order — important for verifying business flows with order dependencies.
  • verifyNever and verifyNoMoreInteractions ensure no unexpected interactions — useful for verifying that error paths don’t perform operations they shouldn’t.
  • Don’t mock pure logic — deterministic functions without I/O or side effects don’t need mocking. Testing directly with real inputs and outputs is far more meaningful.
  • thenAnswer with invocation access enables dynamic responses based on received arguments — useful for mocks that behave differently depending on input.
  • Over-mocking makes tests fragile — tests full of verify for every interaction will break on every implementation refactor, even when external behavior doesn’t change.

← Previous: Unit Testing   Next: JSON →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact