Unit Testing
Write fast, isolated unit tests for services, use cases, and modules without touching the global DI container.
Overview
FakeInjector is an immutable, in-memory implementation of InjectorReader. It allows you to construct objects with their dependencies resolved from a plain map — no Bind.register, no global state, no setUp required.
Use FakeInjector when you want to test a single class in isolation. Use ModularTestScope when you need the full module ecosystem.
FakeInjector
Building an Injector
Start with FakeInjector.empty() and chain add<T>() calls. Each add returns a new injector — the original is never modified:
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_modular/testing.dart';
void main() {
test('OrderService processes payment', () {
final gateway = FakePaymentGateway();
final repo = FakeUserRepository();
final injector = FakeInjector.empty()
.add<PaymentGateway>(gateway)
.add<UserRepository>(repo);
final service = OrderService(
injector.get<PaymentGateway>(),
injector.get<UserRepository>(),
);
final result = service.processOrder(userId: 1, amount: 100);
expect(result, isTrue);
expect(gateway.lastCharged, equals(100));
});
}Resolving Dependencies
injector.get<PaymentGateway>() // returns the registered instance
injector.get<DateTime>() // throws FakeInjectorMissingBindErrorIf a type is not registered, get throws FakeInjectorMissingBindError with a descriptive message:
FakeInjectorMissingBindError: nenhum bind registrado para o tipo DateTime.
Use FakeInjector.empty().add<DateTime>(instance) para registrá-lo.Immutability
add never modifies the receiver. This prevents accidental sharing between tests:
final base = FakeInjector.empty().add<String>('shared');
// Each test gets its own injector without affecting 'base'
final forTest1 = base.add<int>(1);
final forTest2 = base.add<int>(2);FakeInjector API
| Method | Description |
|---|---|
FakeInjector.empty() | Creates an injector with no registrations |
add<T>(instance) | Returns a new injector with T → instance added |
get<T>({String? key}) | Returns the instance for T or throws FakeInjectorMissingBindError |
Testing Services
Service with Multiple Dependencies
class CheckoutService {
final PaymentGateway _gateway;
final InventoryRepository _inventory;
final OrderRepository _orders;
CheckoutService(this._gateway, this._inventory, this._orders);
bool checkout(int productId, int userId) {
if (!_inventory.isAvailable(productId)) return false;
_orders.create(productId, userId);
return _gateway.charge(productId);
}
}
void main() {
FakeInjector buildInjector({
PaymentGateway? gateway,
InventoryRepository? inventory,
OrderRepository? orders,
}) {
return FakeInjector.empty()
.add<PaymentGateway>(gateway ?? FakePaymentGateway())
.add<InventoryRepository>(inventory ?? FakeInventoryRepository())
.add<OrderRepository>(orders ?? FakeOrderRepository());
}
test('checkout succeeds when product is available', () {
final injector = buildInjector();
final service = CheckoutService(
injector.get<PaymentGateway>(),
injector.get<InventoryRepository>(),
injector.get<OrderRepository>(),
);
expect(service.checkout(productId: 1, userId: 1), isTrue);
});
test('checkout fails when product is out of stock', () {
final injector = buildInjector(
inventory: AlwaysUnavailableInventory(),
);
final service = CheckoutService(
injector.get<PaymentGateway>(),
injector.get<InventoryRepository>(),
injector.get<OrderRepository>(),
);
expect(service.checkout(productId: 1, userId: 1), isFalse);
});
}Testing EventModule with FakeInjector
Pass a FakeInjector to module.initState() to initialize a module without the full routing setup:
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_modular/testing.dart';
class AnalyticsModule extends EventModule {
final List<String> trackedEvents = [];
@override
void listen() {
on<PageViewedEvent>((event, _) {
trackedEvents.add(event.pageName);
});
}
@override
List<ModularRoute> get routes => [
ChildRoute('/', child: (_, __) => Container()),
];
}
void main() {
setUp(clearEventModuleState);
test('tracks page views', () async {
final module = AnalyticsModule();
// Initialize with an empty FakeInjector — no DI dependencies needed
module.initState(FakeInjector.empty());
ModularEventBus.fire(PageViewedEvent(pageName: 'HomeScreen'));
await Future.delayed(Duration(milliseconds: 50));
expect(module.trackedEvents, contains('HomeScreen'));
module.dispose();
});
test('stops tracking after dispose', () async {
final module = AnalyticsModule();
module.initState(FakeInjector.empty());
module.dispose();
ModularEventBus.fire(PageViewedEvent(pageName: 'ProfileScreen'));
await Future.delayed(Duration(milliseconds: 50));
expect(module.trackedEvents, isEmpty);
});
}When to Use What
| Scenario | Recommended tool |
|---|---|
| Testing a service or use case in isolation | FakeInjector |
| Testing DI wiring (module registers correct types) | ModularTestScope |
Testing event handling in an EventModule | FakeInjector + ModularEventBus.fire |
| Testing event handling in a widget | clearEventModuleState + ModularEventBus.fire |
| Integration tests with full module lifecycle | ModularTestScope |
Best Practices
- Prefer
FakeInjectorfor unit tests — It has no side effects, runs synchronously, and requires no cleanup. - Build a factory helper — A
buildInjector({...})function with optional overrides keeps tests DRY. - Dispose modules after testing — Always call
module.dispose()in tearDown or at the end of the test to cancel event subscriptions. - Call
clearEventModuleStatein setUp — Prevents subscriptions from a previous test from leaking into the next one when usingEventModuledirectly. - Use
ModularTestScopewhen testing DI resolution — It ensures the global container is clean before each test.