skip to content

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?

level: seniorimportance: should knowfreq 32%

answer

  1. one global registry across tests
  2. Get.find hides dependencies
  3. the first Get.put wins
  4. Get.reset in tearDown
  5. navigation inside controllers

basics

~20 s

GetX 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 s

The 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 lines
dart
import '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

for a junior

Recall that GetX's registry is global, so tests should call Get.reset() and register fakes before the code under test runs.

for a middle

Explain why Get.put does not replace a live instance, how Get.replace differs, and how to trigger onInit and onClose in a test.

for a senior

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.

for a principal

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