A small Flutter prototype built with GetX now needs a test suite for a growing team; what makes its controllers and screens hard to test, and how do you fix that?
answer
- one global registry across tests
- Get.find hides dependencies
- the first Get.put wins
- Get.reset in tearDown
- navigation inside controllers
basics
~20 sGetX state lives in one global registry, so tests leak instances, Get.find hides dependencies and navigating controllers need a navigator. Call Get.reset() in tearDown, register fakes first, inject dependencies by constructor and move navigation behind an interface.
solid answer
~40 sThe prototype's convenience is the problem. `Get.find` calls inside controllers and widgets hide dependencies, so a test cannot see what to fake. The registry is global and survives between tests unless you call `Get.reset()` in `tearDown`. A second `Get.put` of a registered type does not replace the live instance, so a fake registered after the real one is silently ignored: register fakes first, or use `Get.replace`. Controllers that call `Get.to` or `Get.snackbar` need `GetMaterialApp` or `Get.testMode = true`. The fixes: pass repositories into controllers through constructors and keep `Get.put`/`Get.lazyPut` in Bindings; test controllers as plain classes, using `Get.put` and `Get.delete` when you want `onInit` and `onClose`; and let controllers expose outcomes that a thin navigation layer turns into GetX calls.
code
dart · 45 linesimport 'package:flutter_test/flutter_test.dart';
import 'package:get/get.dart';
abstract class HabitRepository {
Future<void> save(String habit);
}
class FakeHabitRepository implements HabitRepository {
final saved = <String>[];
@override
Future<void> save(String habit) async => saved.add(habit);
}
class HabitController extends GetxController {
HabitController(this.repo);
final HabitRepository repo;
final habits = <String>[].obs;
Future<void> add(String habit) async {
await repo.save(habit);
habits.add(habit);
}
@override
void onClose() {
habits.clear();
super.onClose();
}
}
void main() {
// onInit schedules onReady through SchedulerBinding, so a plain test needs a binding
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() => Get.reset()); // no registry state leaks between tests
test('add saves and lists the habit', () async {
final repo = FakeHabitRepository();
final controller = Get.put(HabitController(repo)); // runs onInit
await controller.add('read');
expect(repo.saved, ['read']);
expect(controller.habits, ['read']);
await Get.delete<HabitController>(); // runs onClose
expect(controller.habits, isEmpty);
});
}go deeper
Recall that GetX's registry is global, so tests should call Get.reset() and register fakes before the code under test runs.
Explain why Get.put does not replace a live instance, how Get.replace differs, and how to trigger onInit and onClose in a test.
Lead the refactor: constructor injection with Bindings at route edges, navigation behind an interface, and a shared tearDown, in an order that shows value quickly.
Use the refactor to decide the library's future: once dependencies are explicit, keeping GetX or migrating becomes a scoped, reversible project.
## The situation A two-person prototype, a habit tracker, was built fast with GetX: controllers call `Get.find<HabitRepository>()` wherever they need data, widgets call `Get.put(HabitController())` in `build`, and controllers call `Get.to` and `Get.snackbar` after saving. It works. Now the team is growing and wants unit and widget tests, and the first tests are flaky or impossible to write. ## Why it is hard to test | Symptom | Cause in GetX | |---|---| | A test passes alone and fails in the suite | the registry is global; instances from a previous test are still registered | | A fake repository is ignored | a second `Get.put` of a live type is a no-op; the real one was registered first | | Unclear what to fake | `Get.find` inside methods hides dependencies from the constructor | | Controller test throws about navigation | `Get.to` needs `GetMaterialApp` or `Get.testMode = true` | | `onClose` cleanup never tested | the controller was created with `new`, bypassing GetX's lifecycle | ## Fix 1: isolate every test Call `Get.reset()` in `tearDown`. It clears every registered instance, including permanent ones and services, plus GetX's route bookkeeping and translations. The GetX README recommends exactly this for widget tests and test groups. ## Fix 2: make dependencies explicit Refactor controllers from: ```dart class HabitController extends GetxController { final repo = Get.find<HabitRepository>(); // hidden dependency } ``` to constructor injection, with `Get.find` kept in a `Bindings` class: ```dart class HabitController extends GetxController { HabitController(this.repo); final HabitRepository repo; } class HabitBinding extends Bindings { @override void dependencies() { Get.lazyPut<HabitRepository>(() => SqliteHabitRepository()); Get.lazyPut(() => HabitController(Get.find<HabitRepository>())); } } ``` A unit test then builds `HabitController(FakeHabitRepository())` directly. ## Fix 3: register fakes the right way When a widget test must go through the registry: - register the fake **before** pumping the widget, with an explicit type: `Get.put<HabitRepository>(FakeHabitRepository())`; - if a real instance may already be registered, use `Get.replace<HabitRepository>(FakeHabitRepository())`, which deletes and re-puts; - remove `Get.put` calls from `build` methods; they re-run every rebuild and hide ordering bugs. ## Fix 4: keep the lifecycle testable To exercise `onInit` and `onClose`, let GetX drive them, as the README shows: `Get.put(controller)` runs `onInit`, `Get.delete<HabitController>()` runs `onClose`. Assert the state after each step. `onReady` is scheduled after a frame, so it needs a widget test that pumps. One trap the README example predates: `onInit` schedules `onReady` through `SchedulerBinding`, so in a plain `test()` call `TestWidgetsFlutterBinding.ensureInitialized()` first, or `Get.put` fails with 'Binding has not yet been initialized'. ## Fix 5: move navigation to the edge Controllers that call `Get.to` or `Get.snackbar` couple logic to a global UI. Either: 1. set `Get.testMode = true` for tests that cannot avoid it; or better, 2. expose an outcome (for example an `Rx` event or a result value) and let the page react, or inject a small `Navigation` interface whose GetX implementation calls `Get.to` and whose fake records calls. ## What a healthy GetX test suite looks like - **Controller unit tests** build controllers with fakes through constructors and call `Get.put`/`Get.delete` only when the lifecycle matters. - **Widget tests** pump screens under `GetMaterialApp` when they navigate, with the page's Binding replaced by a test Binding that registers fakes. - **A shared `tearDown`** calls `Get.reset()`, and a shared setup file initialises the test binding. - **No `Get.find` in domain code**: repositories and services know nothing about GetX, so their tests do not either. - **Navigation assertions** go through the fake navigation interface, not through the real navigator stack. Each of these can be introduced one file at a time, which suits a team that must keep shipping while it adds tests. ## Order of work for the team 1. Add `Get.reset()` to shared `tearDown`; flakiness usually drops at once. 2. Convert the most-used controllers to constructor injection with Bindings. 3. Wrap navigation and snackbars behind an interface. 4. Only then decide whether to keep GetX or migrate; the refactor makes either path cheaper.
- A widget test calls Get.put<HabitRepository>(FakeHabitRepository()) after a Binding already registered the real repository; which one does the widget get?The real one. In GetX 4.6.1 `Get.put` does not overwrite a live registration of the same type and tag; it just returns the existing instance. Register the fake before anything registers the real one, or call `Get.replace<HabitRepository>(FakeHabitRepository())`.
- Why are Get.put calls inside build methods a testing hazard?They run on every rebuild and make registration order depend on which widget builds first. A test that registers a fake has to win that race, and a later refactor that reorders widgets silently swaps fake and real instances. Registrations belong in Bindings or setup code.
- A plain test() that calls Get.put(HabitController(repo)) fails with 'Binding has not yet been initialized'. Why?`GetxController.onInit` schedules `onReady` with a post-frame callback on `SchedulerBinding.instance`, and in current Flutter that getter asserts when no binding exists. `testWidgets` sets one up; a plain `test()` does not. Call `TestWidgetsFlutterBinding.ensureInitialized()` at the start of `main()` in the test file.
saying these in an interview costs you the question
- GetX clears its registry automatically between tests
- A later Get.put of a fake replaces the already-registered real instance
- Get.find inside controllers makes dependencies easier to mock
- Creating the controller with its constructor also runs onInit
- Controllers that call Get.to can be unit tested with no setup