Sparround

Fakes vs mocks: practice with mocktail

The official recommendations put "make fakes for testing" at the highest priority, and the reason is concrete: a fake focuses on inputs and outputs rather than an object's inner workings — which forces you to write modular, well-defined interfaces.

The distinction, briefly:

  • A fake is a simplified but working implementation of the contract. Calling add genuinely adds the item; the list is kept in memory. Written once, used across dozens of tests.
  • A mock is an object with no behaviour, programmed to answer instead. Each test writes a when(() => …) setup and verifies calls with verify(…).

The criterion is one question: "am I verifying an outcome, or a call?"

  • An outcome (the cart has three items, the total is 32, the status became cancelled) → a fake.
  • A call (an analytics event was sent, the cache was invalidated, logout ran once) → a mock.

The main mocking tool in Dart is mocktail: it needs no code generation, you declare MockCat extends Mock implements Cat, and calls are written in closures — when(() => cat.sound()).thenReturn('meow').

dart
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';

// 1) Elan: kod generasiyası YOXDUR, `build_runner` lazım deyil.
class MockAnalyticsService extends Mock implements AnalyticsService {}
class MockOrderRepository extends Mock implements OrderRepository {}

// 2) Öz tiplərin `any()` ilə işlədilirsə, fallback qeyd olunmalıdır.
class _FakeOrder extends Fake implements Order {}

void main() {
  setUpAll(() {
    // `any()` matcher-ı işlətməzdən əvvəl bir dəfə çağırılır.
    registerFallbackValue(_FakeOrder());
  });

  late MockAnalyticsService analytics;

  setUp(() {
    analytics = MockAnalyticsService();
    // Void metodlar üçün: thenAnswer((_) async {})
    when(() => analytics.track(any(), properties: any(named: 'properties')))
        .thenAnswer((_) async {});
  });

  test('sifariş verildikdə analitika hadisəsi göndərilir', () async {
    final notifier = CheckoutNotifier(
      repository: FakeOrderRepository([]),   // NƏTİCƏ üçün fake
      analytics: analytics,                  // ÇAĞIRIŞ üçün mock
    );

    await notifier.submit(testForm);

    // Çağırışın yoxlanılması — burada mock haqlıdır,
    // çünki "nəticə" yoxdur: hadisə göndərildi, vəssalam.
    verify(() => analytics.track('order_placed',
        properties: any(named: 'properties'))).called(1);
  });

  test('xəta halında analitika hadisəsi göndərilmir', () async {
    final notifier = CheckoutNotifier(
      repository: FakeOrderRepository([], placeError: const NetworkFailure()),
      analytics: analytics,
    );

    await notifier.submit(testForm);

    verifyNever(() => analytics.track('order_placed',
        properties: any(named: 'properties')));
  });

  test('stub-ın qaytardığı dəyər', () {
    final repo = MockOrderRepository();

    // Sinxron dəyər üçün thenReturn, Future üçün thenAnswer.
    when(() => repo.fetchMine())
        .thenAnswer((_) async => Result.ok(const <Order>[]));

    // Arqumentə görə fərqli cavab:
    when(() => repo.cancel('o-1'))
        .thenAnswer((_) async => Result.ok(cancelledOrder));
    when(() => repo.cancel('o-2'))
        .thenAnswer((_) async => const Result.error(NotFoundFailure()));

    // Xəta atmaq:
    when(() => repo.fetchById(any())).thenThrow(const NetworkFailure());
  });
}

// mocktail-in mockito-dan əsas fərqləri:
//   • kod generasiyası yoxdur (@GenerateMocks, build_runner lazım deyil)
//   • çağırışlar closure-a bükülür: when(() => ...), verify(() => ...)
//   • tip-spesifik matcher-lar (anyString, anyInt) yerinə vahid `any()`,
//     `any(named: '...')`, `any(that: ...)`

mocktail's core API: declaration, stubbing, verification and `registerFallbackValue`.

CriterionFakeMock (mocktail)
What is verifiedThe outcome (state, returned value)That a call happened
Cost to writeOnce, 20-40 linesOne line to declare, but a `when` setup in every test
ReuseHigh — across all testsLow — the setup is test-specific
Sensitivity to refactoringLow — works as long as the contract holdsHigh — breaks when a name or parameter changes
Real behaviourYes (keeps state in memory)No (it only answers)
Best fitRepository, service and use case contractsAnalytics, logging, notifications — side effects

Three properties of a good fake.

1. Working state. It keeps a list or Map in memory, and add, update, delete genuinely take effect. That lets tests verify behaviour like "adding twice increases the quantity". 2. Error injection. Open the error path through a constructor parameter: FakeOrderRepository(const [], fetchError: NetworkFailure()). Then you do not need a separate fake class per error case. 3. Counters. fetchCount, cancelCount — for verifying caching and coalescing behaviour. That gives you something like a mock's verify without losing the fake's behaviour.

When to add latency. A test fake should have no latency — tests should be fast. Latency is needed in only two situations: (1) in a …Local implementation (previous stage — to see the loading state with your own eyes), (2) in one specific test that checks a race condition (via a placeDelay parameter).

Fixtures (test data). Writing Order(id: 'o-1', title: …, status: …, total: …) in every test is duplication and fragile: adding a new required field to the model breaks 40 test files. The fix is builder functions in test/fixtures/: anOrder(status: OrderStatus.pending). When a field is added, only the builder changes.

A mocktail trap. If you use the any() matcher with your own types, registerFallbackValue must be called — otherwise the test throws at runtime. Doing it once in setUpAll is enough.

Practice. Two steps:

1. Create test/fixtures/order_fixtures.dart with a builder: Order anOrder({String? id, OrderStatus? status, double? total}), giving every field a default. 2. Write a complete fake for one repository contract: working state, error injection (a constructor parameter) and two counters. Then use it in at least three tests: a notifier test, a widget test and an error-path test.

Done means: adding a field to the model changes only the builder file (no tests break), and checking an error case needs no new class.

📚 Sources and documentation