skip to content

With get_it, how do you swap the real PaymentService for a fake in tests without registrations leaking from one test case to the next?

level: middleimportance: should knowfreq 40%

answer

  1. one global registry per isolate
  2. register in setUp, reset in tearDown
  3. double registration throws
  4. allowReassignment defaults to false
  5. pushNewScope shadows, popScope restores

basics

~20 s

Register the fake in setUp and await getIt.reset() in tearDown so every test starts empty. To override one type over an existing setup, set allowReassignment or push a new scope, register the fake there, and pop it afterwards.

solid answer

~40 s

`GetIt.instance` is shared state, so a registration made in one test is still there in the next unless you remove it. The usual pattern is to register the fake `PaymentService` in `setUp` and call `await getIt.reset()` in `tearDown`; `reset` removes every registration in reverse order and runs their dispose callbacks. When a test wants the app's full `configureDependencies()` but one fake, registering `PaymentService` again throws an `ArgumentError` because `allowReassignment` is `false` by default. Either set `allowReassignment = true`, or call `pushNewScope()`, register the fake there so it shadows the real one, and `await getIt.popScope()` afterwards. Better still, classes that take dependencies through constructors can be unit-tested without get_it at all.

code

dart · 43 lines
dart
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

abstract class PaymentService {
  Future<void> charge({required int cents});
}

class CardPaymentService implements PaymentService {
  @override
  Future<void> charge({required int cents}) async {/* real API */}
}

class FakePaymentService implements PaymentService {
  final charges = <int>[];
  @override
  Future<void> charge({required int cents}) async => charges.add(cents);
}

void configureDependencies() {
  getIt.registerLazySingleton<PaymentService>(() => CardPaymentService());
}

void main() {
  setUp(configureDependencies);

  tearDown(() async {
    await getIt.reset();
  });

  test('checkout uses the fake, not the card API', () async {
    getIt.pushNewScope(scopeName: 'fake-payments');
    final fake = FakePaymentService();
    getIt.registerSingleton<PaymentService>(fake);

    await getIt<PaymentService>().charge(cents: 450);
    expect(fake.charges, [450]);

    await getIt.popScope(); // the real registration is visible again
    expect(getIt<PaymentService>(), isA<CardPaymentService>());
  });
}

go deeper

for a junior

Remember that get_it registrations persist between tests, so register in setUp and reset in tearDown.

for a middle

Explain the ArgumentError on double registration and the three ways to override: allowReassignment, scopes, unregister.

for a senior

Fix order-dependent suites by moving classes to constructor injection so most tests need no locator, and keep the locator for wiring tests.

for a principal

Set suite-wide conventions for locator use in tests and weigh the speed of shared setup against the isolation of per-test registration.

## Why get_it tests leak get_it's default instance, `GetIt.instance`, is a **global registry**. Tests in the same file share it, so a fake registered in one test is still registered when the next test runs, and a real service registered by an app setup function is still there when a test tries to add a fake. Two symptoms follow: - **`ArgumentError: Type PaymentService is already registered inside GetIt.`** when a second registration of the same type is attempted in the same scope; - **order-dependent results**: a test passes alone and fails in the suite because an earlier test left a fake or a half-used singleton behind. ## Pattern 1: register in setUp, reset in tearDown get_it's README recommends clearing the registry between tests: 1. In `setUp`, register exactly what the test needs, such as a `FakePaymentService` as `PaymentService`. 2. In `tearDown`, `await getIt.reset()`. `reset({bool dispose = true})` removes every registration in the **reverse order** of registration and calls any dispose functions, which is why it must be awaited. Pass `dispose: false` to skip them. ## Pattern 2: override one type Sometimes a test wants the app's real wiring from `configureDependencies()` with only the payment service faked. Three options exist: | Option | How | Trade-off | |---|---|---| | `allowReassignment = true` | register the fake after setup; it replaces the real one | a real double registration in app code is then silently allowed too | | `pushNewScope()` | push a scope, register the fake in it, `popScope()` after | the fake shadows the real service and popping restores it exactly | | `unregister<PaymentService>()` first | remove, then register the fake | throws if nothing is registered, unless `skipUnregisterIfNotRegistered` is set | `pushNewScope` also takes an `init` callback to register the scope's objects and a `scopeName`, and `popScope()` disposes what that scope registered. A separate flag, `skipDoubleRegistration`, marked `@visibleForTesting`, makes a second registration a **no-op**. That is the opposite of what an override needs: the first, real registration wins. ## Pattern 3: do not need the locator in the test If `ParkingSessionViewModel` receives `PaymentService` through its constructor, a unit test builds it with the fake directly and never touches get_it. get_it's README lists constructor injection as a testing aid for this reason. The locator is then only exercised in the few tests that check the wiring itself. ## Widget tests A widget test that pumps a screen resolving its view model through get_it needs the same discipline: register the fakes before `pumpWidget`, reset after the test. Lazy singletons created during one test are also cached across tests unless reset; `resetLazySingleton<T>()` rebuilds a single one on the next access without removing its registration. ## Common mistakes - Calling `configureDependencies()` in `setUpAll` and faking in `setUp`: the second registration throws on every test after the first. - Resetting without `await`: dispose callbacks still run while the next test registers. - Forgetting that `reset()` also removes the real services a later test in the same file expected to find. - Reading a lazy singleton once in a test and assuming the next test gets a fresh one without `reset()` or `resetLazySingleton<T>()`. ## Checklist - Every file that registers something also resets in `tearDown`. - Overrides use scopes rather than a global `allowReassignment` flip where possible. - View models and repositories take dependencies through constructors, so most tests never register anything.

  • Why is await needed on getIt.reset()?
    `reset` returns a `Future<void>` because the dispose functions it calls may be async. Without `await`, the next test's `setUp` can run while disposal is still in progress and hit a registration that has not been removed yet.
  • Is flipping allowReassignment to true for the whole suite a good idea?
    It makes overrides easy but hides real mistakes: a duplicate registration in app code, which normally throws an `ArgumentError`, now silently replaces the earlier object during tests. Scopes give the same override with a clean restore.

saying these in an interview costs you the question

  • Each test automatically gets a fresh GetIt instance.
  • Registering the fake a second time simply replaces the real service by default.
  • reset() only clears lazy singletons and keeps factories.
  • skipDoubleRegistration makes the newest registration win.
  • Popping a scope deletes the registrations made before it was pushed.