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.
Quick Start: The Scope Pattern (Recommended)
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.
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
Pattern 1: Scope-Based Testing (Recommended)
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.
// 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:
// 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.
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.
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.
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.
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
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
// 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
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
class DisposableService implements Disposable {
bool disposed = false;
@override
FutureOr onDispose() {
disposed = true;
}
}Best Practices
✅ Do
Use scopes for test isolation
dartsetUp(() => getIt.pushNewScope()); tearDown(() async => await getIt.popScope());Register real dependencies once in
setUpAll()dartsetUpAll(() { configureDependencies(); // Same as production });Shadow only what you need to mock
dartsetUp(() { getIt.pushNewScope(); getIt.registerSingleton<ApiClient>(MockApiClient()); // Only mock this // Everything else uses real registrations from base scope });Await
popScope()if services have async disposaldarttearDown(() async { Future<void> main() async { await getIt.popScope(); // Ensures cleanup completes }); }Use
allReady()for async registrationsdartFuture<void> main() async { await getIt.allReady(); // Wait before testing }
❌️ Don't
Don't call
reset()between testsdartFuture<void> main() async { // ❌ Bad - loses all registrations tearDown(() async { await getIt.reset(); }); // ✅ Good - use scopes instead tearDown(() async { await getIt.popScope(); }); }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 });Don't use
allowReassignmentin testsdart// ❌ Bad - masks bugs getIt.allowReassignment = true; // ✅ Good - use scopes for isolationDon'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:
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:
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:
test('async test', () async {
Future<void> main() async {
await getIt.allReady(); // Wait for all async registrations
final db = getIt<Database>();
// ...
});
}See Also
- Scopes - Detailed scope documentation
- Object Registration - Registration types