DEV Community

Emin Şahin
Emin Şahin

Posted on

How I Achieved 100% Test Coverage in a Flutter Enterprise App

2,300+ tests. Zero excuses. Here's exactly how I did it.

CI terminal

Github README


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)    │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>>());
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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),
  ],
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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

  1. 100% coverage is achievable if you design for testability from day one
  2. Architecture matters more than test quantity — good structure makes tests write themselves
  3. The test pyramid is real — invest heavily in unit tests
  4. Tools matterbloc_test, mocktail, and very_good_analysis are 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.


flutter #testing #cleanarchitecture #dart #opensource #mobiledevelopment

Top comments (0)