2,300+ tests. Zero excuses. Here's exactly how I did it.
The Challenge
When I started building an enterprise-grade Flutter starter app, I set myself an ambitious goal: 100% test coverage. Not 80%. Not 95%. Full coverage.
Why? Because I wanted to prove that Flutter apps can be tested thoroughly, and that good architecture makes comprehensive testing not just possible, but natural.
Two months later, I hit the target:
- 2,304+ tests
- 100% line coverage
- Zero flaky tests
Here's the playbook.
1. Architecture That Enables Testing
The secret isn't writing more tests—it's writing testable code.
Clean Architecture + Hexagonal Pattern
┌─────────────────────────────────────────────────────────┐
│ PRESENTATION │
│ Widgets, BLoCs, Cubits (UI logic) │
├─────────────────────────────────────────────────────────┤
│ APPLICATION │
│ Use Cases, Services (orchestration) │
├─────────────────────────────────────────────────────────┤
│ DOMAIN │
│ Entities, Ports (interfaces), Value Objects │
├─────────────────────────────────────────────────────────┤
│ INFRASTRUCTURE │
│ Repositories, Data Sources, APIs (implementations) │
└─────────────────────────────────────────────────────────┘
Why this matters for testing:
- Each layer can be tested in isolation
- Dependencies always point inward
- Ports (interfaces) make mocking trivial
CQRS Pattern
I separated queries (read operations) from commands (write operations):
// Query - simple data retrieval
final class GetUserProfile implements UseCase<UserProfile, NoParams> {
GetUserProfile(this._repository);
final IUserRepository _repository;
@override
Future<Either<Failure, UserProfile>> call(NoParams params) {
return _repository.getUserProfile();
}
}
// Command - state mutation
final class UpdateUserProfile implements UseCase<Unit, UpdateProfileParams> {
UpdateUserProfile(this._repository);
final IUserRepository _repository;
@override
Future<Either<Failure, Unit>> call(UpdateProfileParams params) {
return _repository.updateProfile(params.name, params.email);
}
}
Testing benefit: Each use case does ONE thing. One responsibility = one test focus.
2. The Test Pyramid
I followed a strict test pyramid:
╱╲
╱ ╲
╱ E2E╲ (~50 tests)
╱──────╲ Integration tests
╱ Widget ╲ (~400 tests)
╱──────────╲ UI component tests
╱ Unit ╲ (~1,800 tests)
╱──────────────╲ Fast, isolated tests
Unit Tests (~80% of total)
The backbone. Fast, isolated, surgical.
group('GetUserProfile', () {
late GetUserProfile useCase;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
useCase = GetUserProfile(mockRepository);
});
test('returns UserProfile when repository succeeds', () async {
// Arrange
final expectedProfile = UserProfile(id: '1', name: 'John');
when(() => mockRepository.getUserProfile())
.thenAnswer((_) async => Right(expectedProfile));
// Act
final result = await useCase(NoParams());
// Assert
expect(result, Right(expectedProfile));
verify(() => mockRepository.getUserProfile()).called(1);
});
test('returns Failure when repository fails', () async {
// Arrange
when(() => mockRepository.getUserProfile())
.thenAnswer((_) async => Left(ServerFailure()));
// Act
final result = await useCase(NoParams());
// Assert
expect(result, isA<Left<Failure, UserProfile>>());
});
});
Widget Tests (~15% of total)
Test UI components in isolation:
testWidgets('LoginButton shows loading state', (tester) async {
await tester.pumpApp(
BlocProvider<AuthBloc>.value(
value: mockAuthBloc,
child: const LoginButton(),
),
);
// Simulate loading state
whenListen(
mockAuthBloc,
Stream.fromIterable([const AuthState.loading()]),
);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Login'), findsNothing);
});
Integration Tests (~5% of total)
End-to-end flows:
testWidgets('complete login flow', (tester) async {
await tester.pumpWidget(const App());
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
await tester.enterText(find.byKey(Key('password_field')), 'password123');
// Submit
await tester.tap(find.byKey(Key('login_button')));
await tester.pumpAndSettle();
// Verify navigation to dashboard
expect(find.byType(DashboardPage), findsOneWidget);
});
3. Essential Tooling
very_good_analysis
VGV's strict lint rules catch issues before they become bugs:
# analysis_options.yaml
include: package:very_good_analysis/analysis_options.yaml
mocktail for Mocking
Much cleaner than mockito—no code generation needed:
class MockAuthRepository extends Mock implements IAuthRepository {}
// Usage
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => Right(user));
bloc_test for BLoC Testing
Makes testing state management dead simple:
blocTest<AuthBloc, AuthState>(
'emits [loading, authenticated] when login succeeds',
build: () {
when(() => mockLoginUseCase(any()))
.thenAnswer((_) async => Right(user));
return AuthBloc(loginUseCase: mockLoginUseCase);
},
act: (bloc) => bloc.add(LoginRequested(email: 'test@test.com', password: '123')),
expect: () => [
const AuthState.loading(),
AuthState.authenticated(user),
],
);
Coverage Commands
# Run tests with coverage
very_good test --coverage
# Generate HTML report
genhtml coverage/lcov.info -o coverage/html
# Open report
open coverage/html/index.html
4. Patterns That Make Testing Natural
Railway-Oriented Error Handling
Using fpdart's Either type eliminates try-catch chaos:
Future<Either<Failure, User>> login(String email, String password) async {
return _authRepository
.login(email, password)
.flatMap((token) => _userRepository.getUser(token))
.mapLeft((error) => AuthFailure.fromError(error));
}
Testing benefit: Every success AND failure path is explicit and testable.
Dependency Injection with injectable
@injectable
final class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc(this._loginUseCase, this._logoutUseCase) : super(AuthState.initial());
final Login _loginUseCase;
final Logout _logoutUseCase;
}
Testing benefit: Dependencies are injected, not created. Perfect for mocking.
Value Objects
Encapsulate validation in the type system:
final class Email {
factory Email(String value) {
if (!_emailRegex.hasMatch(value)) {
throw InvalidEmailException(value);
}
return Email._(value);
}
const Email._(this.value);
final String value;
}
Testing benefit: Invalid states are unrepresentable. Fewer edge cases to test.
5. Common Challenges & Solutions
Challenge: Testing Async Code
Solution: Use async/await and tester.pumpAndSettle():
testWidgets('shows data after loading', (tester) async {
when(() => mockBloc.state).thenReturn(LoadingState());
await tester.pumpApp(MyWidget());
expect(find.byType(CircularProgressIndicator), findsOneWidget);
when(() => mockBloc.state).thenReturn(LoadedState(data));
await tester.pumpAndSettle();
expect(find.text('Data loaded'), findsOneWidget);
});
Challenge: Testing Navigation
Solution: Use GoRouter with navigatorKey:
testWidgets('navigates to profile on tap', (tester) async {
final router = GoRouter(routes: [...], navigatorKey: navigatorKey);
await tester.pumpWidget(
MaterialApp.router(routerConfig: router),
);
await tester.tap(find.byKey(Key('profile_button')));
await tester.pumpAndSettle();
expect(router.location, '/profile');
});
Challenge: Testing Time-Dependent Code
Solution: Inject a Clock dependency:
final class TokenValidator {
TokenValidator(this._clock);
final Clock _clock;
bool isExpired(Token token) {
return token.expiresAt.isBefore(_clock.now());
}
}
// In tests
final mockClock = MockClock();
when(() => mockClock.now()).thenReturn(DateTime(2026, 2, 19));
6. The Results
After 2 months of disciplined testing:
| Metric | Value |
|---|---|
| Total tests | 2,304 |
| Line coverage | 100% |
| Branch coverage | 98%+ |
| Flaky tests | 0 |
| CI time | ~4 minutes |
What I Learned
- 100% coverage is achievable if you design for testability from day one
- Architecture matters more than test quantity — good structure makes tests write themselves
- The test pyramid is real — invest heavily in unit tests
-
Tools matter —
bloc_test,mocktail, andvery_good_analysisare game-changers
A Word of Caution
100% coverage isn't always the right goal. For this starter app, it made sense—it's a reference implementation meant to demonstrate best practices. But in production projects:
- Refactoring becomes expensive — Every code change requires updating tests
- Diminishing returns — Going from 80% to 100% often costs more than it's worth
- False confidence — High coverage doesn't mean high-quality tests
Aim for meaningful coverage (70-90%) on critical paths rather than chasing a number. Test the logic that matters, not every getter and setter.
Try It Yourself
The entire codebase is open source:
🔗 GitHub: github.com/deveminsahin/starter_app
Every pattern I described is implemented and tested. Clone it, explore it, steal from it.
What's Next?
I'm writing follow-up articles on:
- Railway-Oriented Error Handling in Flutter
- Clean Architecture + DDD for Flutter
- Mason Bricks for Feature Generation
Follow me for updates!
Found this helpful? Give the repo a ⭐ and share with a Flutter developer who might benefit.


Top comments (0)