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 aGetMaterialAppis mounted. - The services you register. The package documentation is blunt about
GetxService: it “is not automatically disposed (nor can be removed withGet.delete())” and the only way to remove it isGet.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:
testModeis 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.- What it gates is route data. When test mode is active and no GetX tree has been initialized,
Get.argumentsandGet.parametersare served from the test kit instead of walking the root delegate that aGetMaterialAppwould create. That is why controllers which readGet.argumentsinonInitblow up in bare unit tests without it. - The test kit is not part of the public API surface.
package:get/get.dartexportsget_navigation.dart, which exportssrc/routes/index.dart, andtest_kit.dartis in neither chain.Get.testModeis the supported handle; reachingGetTestMode.setTestArgumentsmeans a deep import intosrc/, 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, notsetUpAll. A mutable fake shared across tests is state leakage with extra steps. Get.reset()is the only full teardown. Controllers go away withGet.delete(), but services registered asGetxServicesurvive it by design, so a suite that only ever deletes controllers drifts.- Set
Get.testModeinsidesetUptoo. 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.putreturning 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 rawMaterialApp. Contextless navigation, snackbars and dialogs all assume GetX’s root. If you insist onMaterialApp, wire it up manually withnavigatorKey: Get.keyandnavigatorObservers: [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 atGet.offAllas 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 type | What to put under test | GetX-specific setup | Typical failure without it |
|---|---|---|---|
| Unit | Controller logic, .obs state, GetConnect decoders | Get.testMode = true, fakes via Get.put, Get.reset() in teardown | Stale registry: previous test’s controller answers Get.find |
| Widget | Obx/GetBuilder rebuilds, form validation, bindings | Pump GetMaterialApp, register the controller first | Missing overlay/snackbar errors; Get.back() throws |
| Integration | Full navigation flows on a device | Nothing special; the real app boots its own bindings | Native dialogs the Flutter test binding cannot dismiss - the Flutter docs recommend patrol for this |
| CI | All of the above | Deterministic seeds, no setUpAll fakes, reset every file | Order-dependent passes that break only when files run in parallel |
The flakiness checklist of causes
Get.testModeset once and never reset. It staystruefor 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 aGetxService, outlivesGet.delete(). OnlyGet.reset()clears it. - Lazy singletons with hidden state.
Get.lazyPutdefers construction to firstGet.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.toNamedin a unit test, either pump aGetMaterialAppin 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 exampleclass NotificationServiceMock extends GetxService with Mock implements NotificationService {}, then register the mock withGet.putsoGet.findresolves it.
Checklist
Get.testMode = trueset insetUpof every file whose controllers readGet.argumentsorGet.parameters.Get.reset()called intearDown, not only in the occasional test, becauseGetxServiceinstances ignoreGet.delete().- Fakes registered per test in
setUp; no mutable fakes insetUpAll. - Lifecycle assertions placed at all three points: before
Get.put, afteronInit, afterGet.delete. - Widget tests pump
GetMaterialApp(or a manually wiredMaterialAppwithGet.key) rather than a bareMaterialApp. - Navigation-heavy flows use
Get.offAllso route stacks do not accumulate across tests. - Deep imports into GetX
src/avoided; the test kit is not exported frompackage: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