Skip to content

Testing

Testing code that uses get_it requires different approaches depending on whether you're writing unit tests, widget tests, or integration tests. This guide covers best practices and common patterns.

Best practice: Use scopes to shadow real services with test doubles. This is cleaner and more maintainable than resetting get_it or using conditional registration.

dart
setUpAll(() {
  configureDependencies(); // Register real app dependencies ONCE
});

setUp(() {
  getIt.pushNewScope(); // Create test scope
  getIt.registerSingleton<ApiClient>(MockApiClient()); // Shadow with mock
});

tearDown(() async {
  await getIt.popScope(); // Restore real services
});

test('test name', () {
  final service = getIt<UserService>();
  print('service: $service');
  // UserService automatically gets MockApiClient!
});

Key benefits:

  • Only override what you need for each test

  • Automatic cleanup between tests

  • Same configureDependencies() as production


Unit Testing Patterns

Use scopes to inject mocks for specific services while keeping the rest of your dependency graph intact. Registering a different implementation in a scope works the same way - using the generic type parameter to shadow the original registration.

dart
// Note: This example demonstrates testing patterns
// In actual tests, use flutter_test and mockito packages

class MockApiClient extends ApiClient {
  MockApiClient() : super('http://mock');

  String token = 'test-token';
  @override
  Future<Map<String, dynamic>> get(String endpoint,
      {Map<String, String>? headers}) async {
    return {'id': '123', 'name': 'Alice'};
  }
}

class MockAuthService {
  String getToken() => 'test-token';

  Future<bool> login(String username, String password) async => true;
  Future<void> logout() async {}
  Future<void> cleanup() async {}
  bool get isAuthenticated => false;
}

void main() {
  // Simplified test example showing scope-based mocking
  void setupTests() {
    // Register all real dependencies once
    getIt.registerLazySingleton<ApiClient>(() => ApiClientImpl());
    getIt.registerLazySingleton<AuthService>(() => AuthServiceImpl());
    getIt.registerLazySingleton<UserRepository>(
        () => UserRepository(getIt(), getIt()));
  }

  void runTest() async {
    // Push scope and shadow only the services we want to mock
    getIt.pushNewScope();

    final mockApi = MockApiClient();
    final mockAuth = MockAuthService();

    getIt.registerSingleton<ApiClient>(mockApi);
    getIt.registerSingleton<MockAuthService>(mockAuth);

    // UserRepository will be created fresh with our mocks
    final repo = getIt<UserRepository>();
    print('repo: $repo');
    final user = await repo.fetchUser('123');

    print('Fetched user: ${user?.name}');

    // Clean up
    await getIt.popScope();
  }

  setupTests();
  runTest();
}

Pattern 2: Conditional Registration (Alternative)

Instead of using scopes, you can switch implementations at registration time using a flag:

dart
// Interface
abstract class PaymentProcessor {
  Future<void> processPayment(double amount);
}

// Real implementation
class StripePaymentProcessor implements PaymentProcessor {
  @override
  Future<void> processPayment(double amount) async {
    // Real Stripe API call
  }
}

// Mock implementation for testing
class MockPaymentProcessor implements PaymentProcessor {
  @override
  Future<void> processPayment(double amount) async {
    // Mock - no real API call
  }
}

// Configure with conditional registration
void configureDependencies({bool isTesting = false}) {
  if (isTesting) {
    // Register mock for testing
    getIt.registerSingleton<PaymentProcessor>(MockPaymentProcessor());
  } else {
    // Register real implementation for production
    getIt.registerSingleton<PaymentProcessor>(StripePaymentProcessor());
  }
}

// Business logic - works with either implementation!
class CheckoutService {
  Future<void> processOrder(double amount) {
    // The <PaymentProcessor> type parameter is what enables the switch
    // Without it, mock and real would register as different types
    final processor = getIt<PaymentProcessor>();
    return processor.processPayment(amount);
  }
}

This approach is simpler but less flexible than scopes - you must decide which implementation to use before registration, and can't easily switch during runtime.

Type-Based Shadowing

When you register MockPaymentProcessor as <PaymentProcessor>, get_it uses the type parameter as the lookup key, not the concrete class. This is what enables switching implementations—the same key retrieves different objects in different contexts.

Pattern 3: Constructor Injection for Pure Unit Tests

For testing classes in complete isolation (without get_it), use optional constructor parameters.

dart
class UserManager {
  final AppModel appModel;
  final DbService dbService;

  UserManager({
    AppModel? appModel,
    DbService? dbService,
  })  : appModel = appModel ?? getIt<AppModel>(),
        dbService = dbService ?? getIt<DbService>();

  Future<void> saveUser(User user) async {
    appModel.currentUser = user;
    await dbService.save(user);
  }
}

// In tests - no get_it needed
// test('saveUser updates model and persists to database', () async {
//   final mockModel = MockAppModel();
//   final mockDb = MockDbService();
//
//   // Create instance directly with mocks
//   final manager = UserManager(appModel: mockModel, dbService: mockDb);
//
//   await manager.saveUser(User(id: '1', name: 'Bob'));
//
//   verify(mockDb.save(any)).called(1);
// });

When to use:

  • ✅ Testing pure business logic in isolation
  • ✅ Classes that don't need the full dependency graph
  • ❌️ Integration-style tests where you want real dependencies

Widget Testing

Testing Widgets That Use get_it

Widgets often retrieve services from get_it. Use scopes to provide test-specific implementations.

dart
setUpAll(() {
  // Register app dependencies
  getIt.registerLazySingleton<ThemeService>(() => ThemeServiceImpl());
  getIt.registerLazySingleton<UserService>(
      () => UserServiceImpl(ApiClient('http://localhost')));
});

testWidgets('LoginPage displays user after successful login', (tester) async {
  // Arrange - push scope with mock
  getIt.pushNewScope();
  final mockUser = MockUserService();
  getIt.registerSingleton<UserService>(mockUser);

  // Act
  await tester.pumpWidget(const MyApp());
  await tester.enterText(find.byKey(const Key('username')), 'alice');
  await tester.enterText(find.byKey(const Key('password')), 'secret');
  await tester.tap(find.text('Login'));
  await tester.pumpAndSettle();

  // Assert
  expect(find.text('Welcome, Alice'), findsOneWidget);

  // Cleanup
  await getIt.popScope();
});

Testing with Async Registrations

If your app uses registerSingletonAsync, ensure async services are ready before testing.

dart
test('widget works with async services', () async {
  getIt.pushNewScope();

  // Register async mock
  getIt.registerSingletonAsync<Database>(() async {
    await Future.delayed(Duration(milliseconds: 100));
    return MockDatabase();
  });

  // Wait for all async registrations
  await getIt.allReady();

  // Now safe to test
  final db = getIt<Database>();
  print('db: $db');
  expect(db, isA<MockDatabase>());

  await getIt.popScope();
});

Testing Factories

Testing Factory Registrations

Factories create new instances on each get() call - verify this behavior in tests.

dart
void main() {
  test('factory creates new instance each time', () async {
    getIt.pushNewScope();

    getIt.registerFactory<ShoppingCart>(() => ShoppingCart());

    final cart1 = getIt<ShoppingCart>();
    print('cart1: $cart1');
    final cart2 = getIt<ShoppingCart>();
    print('cart2: $cart2');

    expect(identical(cart1, cart2), false); // Different instances

    await getIt.popScope();
  });
}

Testing Parameterized Factories

dart
test('factory param passes parameters correctly', () async {
  getIt.pushNewScope();

  getIt.registerFactoryParam<UserViewModel, String, void>(
    (userId, _) => UserViewModel(userId),
  );

  final vm = getIt<UserViewModel>(param1: 'user-123');
  print('vm: $vm');
  expect(vm.userId, 'user-123');

  await getIt.popScope();
});

Common Testing Scenarios

Scenario 1: Testing Service with Multiple Dependencies
dart
// Service under test - uses get_it directly when accessing dependencies
class SyncService {
  Future<void> syncData() async {
    if (!getIt<AuthService>().isAuthenticated) return;
    final data = await getIt<ApiClient>().fetchData();
    await getIt<Database>().save(data);
  }
}

void main() {
  test('SyncService uses mocked dependencies', () async {
    getIt.pushNewScope();

    // Register mocks - SyncService will get these via getIt<Type>()
    getIt.registerSingleton<AuthService>(
        MockAuthService()..isAuthenticated = true);
    getIt.registerSingleton<ApiClient>(
        MockApiClient()..mockData = {'data': 'value'});
    getIt.registerSingleton<Database>(MockDatabase());

    // Service under test uses get_it to access mocks
    final sync = SyncService();
    await sync.syncData();

    // Test assertions would go here...

    await getIt.popScope();
  });
Scenario 2: Testing Scoped Services
dart
test('service lifecycle matches scope lifecycle', () async {
  // Base scope
  getIt.registerLazySingleton<CoreService>(() => CoreService());

  // Feature scope
  getIt.pushNewScope(scopeName: 'feature');
  getIt.registerLazySingleton<FeatureService>(() => FeatureService(getIt()));

  expect(getIt<CoreService>(), isNotNull);
  expect(getIt<FeatureService>(), isNotNull);

  // Pop feature scope
  await getIt.popScope();

  expect(getIt<CoreService>(), isNotNull); // Still available
  expect(() => getIt<FeatureService>(), throwsStateError); // Gone!
});
Scenario 3: Testing Disposal
dart
class DisposableService implements Disposable {
  bool disposed = false;

  @override
  FutureOr onDispose() {
    disposed = true;
  }
}

Best Practices

✅ Do

  1. Use scopes for test isolation

    dart
    setUp(() => getIt.pushNewScope());
       tearDown(() async => await getIt.popScope());
  2. Register real dependencies once in setUpAll()

    dart
    setUpAll(() {
         configureDependencies(); // Same as production
       });
  3. Shadow only what you need to mock

    dart
    setUp(() {
         getIt.pushNewScope();
         getIt.registerSingleton<ApiClient>(MockApiClient()); // Only mock this
         // Everything else uses real registrations from base scope
       });
  4. Await popScope() if services have async disposal

    dart
    tearDown(() async {
    
    Future<void> main() async {
         await getIt.popScope(); // Ensures cleanup completes
       });
    }
  5. Use allReady() for async registrations

    dart
    Future<void> main() async {
      await getIt.allReady(); // Wait before testing
    }

❌️ Don't

  1. Don't call reset() between tests

    dart
    Future<void> main() async {
      // ❌ Bad - loses all registrations
      tearDown(() async {
        await getIt.reset();
      });
    
      // ✅ Good - use scopes instead
      tearDown(() async {
        await getIt.popScope();
      });
    }
  2. Don't re-register everything in each test

    dart
    // ❌ Bad - duplicates production setup
       setUp(() {
         getIt.registerLazySingleton<ApiClient>(...);
         getIt.registerLazySingleton<Database>(...);
         // ... 50 more registrations
       });
    
       // ✅ Good - reuse production setup
       setUpAll(() {
         configureDependencies(); // Call once
       });
  3. Don't use allowReassignment in tests

    dart
    // ❌ Bad - masks bugs
       getIt.allowReassignment = true;
    
       // ✅ Good - use scopes for isolation
  4. Don't forget to pop scopes in tearDown

    dart
    // ❌ Bad - scopes leak into next test
       test('...', () {
         getIt.pushNewScope();
         // ... test code
         // Missing popScope()!
       });

Troubleshooting

"Object/factory already registered" in tests

Cause: Scope wasn't popped in previous test, or reset() wasn't awaited.

Fix:

dart
tearDown(() async {

Future<void> main() async {
  await getIt.popScope(); // Always await!
  });
}

Mocks not being used

Cause: Mock was registered in wrong scope or after service was already created.

Fix: Push scope and register mocks before accessing services:

dart
setUp(() {
  getIt.pushNewScope();
  getIt.registerSingleton<ApiClient>(mockApi); // Register FIRST
});

test('test name', () {
  final service = getIt<UserService>(); // Accesses AFTER mock registered
  // ...
});

Async service not ready

Cause: Trying to access async registration before it completes.

Fix:

dart
test('async test', () async {

Future<void> main() async {
  await getIt.allReady(); // Wait for all async registrations
  final db = getIt<Database>();
  // ...
  });
}

See Also

Released under the MIT License.