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?
answer
- one global registry per isolate
- register in setUp, reset in tearDown
- double registration throws
- allowReassignment defaults to false
- pushNewScope shadows, popScope restores
basics
~20 sRegister 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 linesimport '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
Remember that get_it registrations persist between tests, so register in setUp and reset in tearDown.
Explain the ArgumentError on double registration and the three ways to override: allowReassignment, scopes, unregister.
Fix order-dependent suites by moving classes to constructor injection so most tests need no locator, and keep the locator for wiring tests.
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.