LogoModularity

🧪 Testing Modules#

Test Modularity modules with modularity_test for unit tests and ModuleScope for widget tests.

Choosing the Right Test Type#

Mermaid Loading diagram preview

Rendering the Mermaid source into a visual diagram. This usually takes a moment.

Setup#

dev_dependencies:
  modularity_test:
    path: ../packages/modularity_test  # or published version
  test: ^1.25.0

Unit Testing with testModule()#

testModule() runs the full module lifecycle (resolve imports, binds, overrides, exports, onInit) and provides a TestBinder that records all registrations and resolutions. The controller is automatically disposed after the body runs.

import 'package:modularity_core/modularity_core.dart';
import 'package:modularity_test/modularity_test.dart';
import 'package:test/test.dart';

class AuthModule extends Module {
  @override
  void binds(Binder binder) {
    binder.registerLazySingleton<ApiClient>(() => ApiClient());
    binder.registerFactory<AuthRepository>(
      () => AuthRepository(binder.get<ApiClient>()),
    );
  }
}

test('AuthModule registers expected dependencies', () async {
  await testModule(AuthModule(), (module, binder) {
    expect(binder.hasSingleton<ApiClient>(), isTrue);
    expect(binder.hasFactory<AuthRepository>(), isTrue);

    final repo = binder.get<AuthRepository>();
    expect(repo, isA<AuthRepository>());
    expect(binder.wasResolved<AuthRepository>(), isTrue);
  });
});

TestBinder API#

::: info TestBinder reference

MethodDescription
hasSingleton<T>() Was T registered via registerLazySingleton?
hasFactory<T>() Was T registered via registerFactory?
hasInstance<T>() Was T registered via registerSingleton (eager)?
wasResolved<T>() Was T retrieved via get or tryGet?
registeredSingletonsAll types registered as lazy singletons
registeredFactoriesAll types registered as factories
registeredInstancesAll types registered as eager instances
resolvedTypesAll types that were resolved
:::

Overriding Dependencies in Tests#

Pass overrides to replace real implementations with fakes:

test('override HttpClient with fake', () async {
  await testModule(
    NetworkModule(),
    overrides: (binder) {
      binder.registerSingleton<HttpClient>(FakeHttpClient());
    },
    (module, binder) {
      expect(binder.get<HttpClient>(), isA<FakeHttpClient>());
    },
  );
});

::: tip Use overrideScope when you need to override bindings in deeply nested imported modules. It lets you target specific child modules without affecting the rest of the dependency tree. :::

For modules with imports, use overrideScope to target specific child modules:

test('override imported module bindings', () async {
  await testModule(
    AppModule(),
    overrideScope: ModuleOverrideScope(
      children: {
        DataModule: ModuleOverrideScope(
          selfOverrides: (binder) {
            binder.registerLazySingleton<DatabaseService>(
              () => FakeDatabaseService(),
            );
          },
        ),
      },
    ),
    (module, binder) {
      expect(binder.get<DatabaseService>().name, equals('fake'));
    },
  );
});

Widget Testing#

Wrap widgets in ModularityRoot + ModuleScope:

testWidgets('ProfilePage shows user name', (tester) async {
  await tester.pumpWidget(
    ModularityRoot(
      child: MaterialApp(
        home: ModuleScope(
          module: ProfileModule(),
          overrides: (binder) {
            binder.registerLazySingleton<UserService>(
              () => FakeUserService(),
            );
          },
          child: const ProfilePage(),
        ),
      ),
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Test User'), findsOneWidget);
});

Override scopes in widget tests#

testWidgets('override imported module in widget tree', (tester) async {
  await tester.pumpWidget(
    ModularityRoot(
      child: MaterialApp(
        home: ModuleScope(
          module: AppModule(),
          overrideScope: ModuleOverrideScope(
            children: {
              AuthModule: ModuleOverrideScope(
                selfOverrides: (binder) {
                  binder.registerLazySingleton<AuthService>(
                    () => FakeAuthService(),
                  );
                },
              ),
            },
          ),
          child: const AppPage(),
        ),
      ),
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Logged in as: test-user'), findsOneWidget);
});

Testing Error Scenarios#

::: details Error Scenarios (advanced)

Circular dependency detection#

test('circular imports throw CircularDependencyException', () async {
  final controller = ModuleController(ModuleA()); // A imports B, B imports A

  await expectLater(
    () => controller.initialize(<ModuleRegistryKey, ModuleController>{}),
    throwsA(isA<CircularDependencyException>()),
  );
});

Missing expects validation#

test('missing expects throw ModuleConfigurationException', () async {
  final controller = ModuleController(StrictModule()); // expects [AuthService]

  await expectLater(
    () => controller.initialize(<ModuleRegistryKey, ModuleController>{}),
    throwsA(isA<ModuleConfigurationException>()),
  );
});

Error recovery in widgets#

testWidgets('retry recovers from init failure', (tester) async {
  FlakyModule.attempts = 0;

  await tester.pumpWidget(
    ModularityRoot(
      child: MaterialApp(
        home: ModuleScope(
          module: FlakyModule(), // throws on first attempt
          child: const Text('Success'),
        ),
      ),
    ),
  );

  await tester.pump(const Duration(milliseconds: 100));
  expect(find.text('Module Init Failed'), findsOneWidget);

  await tester.tap(find.text('Retry'));
  await tester.pumpAndSettle();
  expect(find.text('Success'), findsOneWidget);
});

:::

Testing Async onInit#

testModule() awaits the full lifecycle including onInit():

test('onInit completes before test body', () async {
  await testModule(CacheModule(), (module, binder) {
    expect(module.initialized, isTrue); // set in async onInit()
  });
});

Manual Controller Tests#

For tests that need direct ModuleController access, always dispose manually:

test('manual controller lifecycle', () async {
  final controller = ModuleController(MyModule());

  await controller.initialize(<ModuleRegistryKey, ModuleController>{});
  // ... assertions ...

  await controller.dispose();
  expect(controller.currentStatus, equals(ModuleStatus.disposed));
});

::: warning testModule() automatically disposes the controller. Only create ModuleController directly when you need to test lifecycle states or interceptor behavior. :::