Skip to content
Blog

Testing GetX Apps: testMode, Controller Lifecycles, and Workarounds

GetX tests fail for boring reasons: global singletons, a live router, and a lifecycle nobody resets. What Get.testMode actually does, how to drive onInit and onClose, and how to keep CI deterministic.

Published on • October 17, 2026

AI Assistant

GetX is the easiest state management option to write and one of the awkward ones to test. The same design that removes boilerplate from your app - a process-wide dependency registry, contextless navigation, controllers that create themselves - works against you the moment the second test file runs in the same Dart isolate.

You have seen the symptoms. Test three passes in isolation and fails in CI. A controller still holds last test’s list. Get.arguments returns stale data. A service registered as permanent is never cleaned up. The usual advice on the internet is one line long: set Get.testMode = true. That line is real, but it solves a much smaller problem than people assume.

This post goes deeper than that line. We will read what testMode actually toggles in the GetX source, build a lifecycle test that exercises onInit and onClose the way GetX really runs them, and put a reset harness in place that keeps a suite deterministic. Existing GetX coverage tends to mention testing in a single table cell; this is the long version.

Why GetX tests leak in the first place

Flutter’s own testing guide splits tests into unit, widget, and integration tests, and is explicit about the trade-offs: unit tests are quick with few dependencies, widget tests cost more, integration tests run slowest on a real device with the most confidence. The mechanics are the same for a GetX app. What changes is the blast radius, because GetX moves three things out of the widget tree and into globals:

  • The dependency registry (Get.put, Get.find, Get.lazyPut) is a singleton. Anything you put in one test is still there in the next one.
  • The navigation state (Get.toNamed, Get.back, Get.currentRoute) lives behind a root delegate that only exists once a GetMaterialApp is mounted.
  • The services you register. The package documentation is blunt about GetxService: it “is not automatically disposed (nor can be removed with Get.delete())” and the only way to remove it is Get.reset().

That last point is where CI flakiness usually comes from. A test registers an AuthService as a GetxService, the next ten tests inherit it, and only the first test to call Get.reset() actually clears the board. If nothing calls it, state rides along for the whole file.

Compare that with a Riverpod test, which gets an isolated ProviderContainer, or a Bloc test, which constructs a bloc instance directly. With GetX you are not isolating by default; you are opting into isolation. The rest of this post is the opt-in.

What Get.testMode actually does

Here is the entire mechanism, straight from the GetX source in lib/get_navigation/src/extension_navigation.dart:

// A plain setter over a static flag.
set testMode(bool isTest) => GetTestMode.active = isTest;
bool get testMode => GetTestMode.active;

// Used by the arguments and parameters getters:
bool get _shouldUseMock => GetTestMode.active && !GetRoot.treeInitialized;

dynamic get arguments {
  if (_shouldUseMock) {
    return GetTestMode.arguments as T;
  }
  return rootController.rootDelegate.arguments<T>();
}

Map<String, String?> get parameters {
  if (_shouldUseMock) {
    return GetTestMode.parameters;
  }
  return rootController.rootDelegate.parameters;
}

And the flag lives in lib/get_navigation/src/routes/test_kit.dart:

class GetTestMode {
  static bool active = false;
  static Object? _arguments;

  static void setTestArguments(Object? arguments) {
    _arguments = arguments;
  }

  static Object? get arguments => _arguments;

  static Map<String, String?> _parameters = {};

  static void setTestParameters(Map<String, String?> parameters) {
    _parameters = parameters;
  }

  static Map<String, String?> get parameters => _parameters;
}

Three things worth internalizing:

  1. testMode is a single static boolean. It is not a test harness, and it does not reset your controllers. Turning it on in one test leaves it on for every test that runs afterwards in that isolate.
  2. What it gates is route data. When test mode is active and no GetX tree has been initialized, Get.arguments and Get.parameters are served from the test kit instead of walking the root delegate that a GetMaterialApp would create. That is why controllers which read Get.arguments in onInit blow up in bare unit tests without it.
  3. The test kit is not part of the public API surface. package:get/get.dart exports get_navigation.dart, which exports src/routes/index.dart, and test_kit.dart is in neither chain. Get.testMode is the supported handle; reaching GetTestMode.setTestArguments means a deep import into src/, which you should treat as unstable across upgrades.

So: use Get.testMode = true when your controller logic reads route arguments or parameters, and do not expect it to fix state leakage. State leakage is fixed by registration discipline, below.

Driving the controller lifecycle

GetX documentation shows the pattern it intends: Get.put is what runs onInit, Get.delete is what runs onClose, and a controller is an ordinary class you can assert against between those two calls.

// controller under test
class CartController extends GetxController {
  final items = <String>[].obs;
  final loading = false.obs;

  @override
  void onInit() {
    super.onInit();
    load();
  }

  Future<void> load() async {
    loading.value = true;
    items.value = await Get.find<CatalogRepository>().fetchCart();
    loading.value = false;
  }

  void add(String sku) => items.add(sku);

  @override
  void onClose() {
    items.close();
    super.onClose();
  }
}
// lifecycle test - no widget pumping required
void main() {
  setUp(() {
    Get.testMode = true;
    Get.put<CatalogRepository>(FakeCatalogRepository());
  });

  tearDown(() => Get.reset());

  test('onInit loads the cart, onClose releases the stream', () async {
    final controller = CartController();

    // Registration is what starts the lifecycle.
    Get.put<CartController>(controller);
    await controller.load();

    expect(controller.loading.value, isFalse);
    expect(controller.items, isNotEmpty);

    controller.add('SKU-1');
    expect(controller.items, hasLength(4));

    Get.delete<CartController>();
    // onClose has run: the Rx stream is closed, state is no longer usable.
    expect(() => controller.items.listen((_) {}), throwsA(isA<StateError>()));
  });
}

The shape matters: assert before registration (constructor defaults), after Get.put (post-onInit), and after Get.delete (post-onClose). If your assertions only ever run after Get.put, you are testing the constructor, not the lifecycle.

The reset harness

Every test file that touches the registry should start and end the same way:

void main() {
  setUp(() {
    Get.testMode = true;
    // Re-register fakes fresh for every test, never in a shared setUpAll.
    Get.put<CatalogRepository>(FakeCatalogRepository());
  });

  tearDown(() {
    Get.reset(); // clears the registry, including GetxService instances
  });
  ...
}

Notes that save hours later:

  • Put fakes in setUp, not setUpAll. A mutable fake shared across tests is state leakage with extra steps.
  • Get.reset() is the only full teardown. Controllers go away with Get.delete(), but services registered as GetxService survive it by design, so a suite that only ever deletes controllers drifts.
  • Set Get.testMode inside setUp too. It is a global static; making your setup explicit costs one line and removes any dependence on what a previous file did.
  • Order your assertions against the registry, not the widget. Get.put returning an instance tells you registration worked; only a widget test tells you the UI rendered it.

Widget tests: pump the router you actually depend on

When the code under test navigates or shows overlays, a bare unit test is the wrong tool. Give it the minimum GetX tree:

testWidgets('tapping checkout routes to the summary screen', (tester) async {
  Get.testMode = true;
  Get.put<CatalogRepository>(FakeCatalogRepository());
  final cart = Get.put(CartController())..add('SKU-1');

  await tester.pumpWidget(
    GetMaterialApp(
      home: CartPage(controller: cart),
    ),
  );

  await tester.tap(find.byKey(const Key('checkout')));
  await tester.pumpAndSettle();

  expect(find.byType(CheckoutSummaryPage), findsOneWidget);

  Get.reset();
});

Two habits keep these stable:

  • Always pump GetMaterialApp, never a raw MaterialApp. Contextless navigation, snackbars and dialogs all assume GetX’s root. If you insist on MaterialApp, wire it up manually with navigatorKey: Get.key and navigatorObservers: [GetObserver()], which is how the package documents a manual configuration.
  • Prefer Get.offAll(...) in test flows when you need a clean stack. The package’s own README points at Get.offAll as useful “for shopping carts, polls, and tests” precisely because it drops the previous routes instead of leaving them stacked for the next assertion.

Where each test type fits a GetX app

Test typeWhat to put under testGetX-specific setupTypical failure without it
UnitController logic, .obs state, GetConnect decodersGet.testMode = true, fakes via Get.put, Get.reset() in teardownStale registry: previous test’s controller answers Get.find
WidgetObx/GetBuilder rebuilds, form validation, bindingsPump GetMaterialApp, register the controller firstMissing overlay/snackbar errors; Get.back() throws
IntegrationFull navigation flows on a deviceNothing special; the real app boots its own bindingsNative dialogs the Flutter test binding cannot dismiss - the Flutter docs recommend patrol for this
CIAll of the aboveDeterministic seeds, no setUpAll fakes, reset every fileOrder-dependent passes that break only when files run in parallel

The flakiness checklist of causes

  • Get.testMode set once and never reset. It stays true for the rest of the isolate, silently changing how arguments and parameters are read in later tests.
  • Permanent dependencies. Anything registered with permanent: true, or as a GetxService, outlives Get.delete(). Only Get.reset() clears it.
  • Lazy singletons with hidden state. Get.lazyPut defers construction to first Get.find, so a controller can initialize during whatever test touches it first - not the one that meant to test it.
  • Navigation assertions without a router. If a controller calls Get.toNamed in a unit test, either pump a GetMaterialApp in a widget test or assert on your own seam instead of the global router.
  • Mocks that are not controllers. GetX’s own guidance for mocking is to subclass the concrete type and mix in Mock, for example class NotificationServiceMock extends GetxService with Mock implements NotificationService {}, then register the mock with Get.put so Get.find resolves it.

Checklist

  • Get.testMode = true set in setUp of every file whose controllers read Get.arguments or Get.parameters.
  • Get.reset() called in tearDown, not only in the occasional test, because GetxService instances ignore Get.delete().
  • Fakes registered per test in setUp; no mutable fakes in setUpAll.
  • Lifecycle assertions placed at all three points: before Get.put, after onInit, after Get.delete.
  • Widget tests pump GetMaterialApp (or a manually wired MaterialApp with Get.key) rather than a bare MaterialApp.
  • Navigation-heavy flows use Get.offAll so route stacks do not accumulate across tests.
  • Deep imports into GetX src/ avoided; the test kit is not exported from package:get/get.dart.
  • CI runs the suite twice in different file orders before you trust a green build.

Sources: get on pub.dev, Testing Flutter apps, Flutter State Management in 2026: Riverpod vs Bloc vs GetX, GetX source: extension_navigation.dart, GetX source: test_kit.dart