Flutter Testing That Actually Works: From Unit to Integration
Flutter Development
11 min read
September 20, 2025

Flutter Testing That Actually Works: From Unit to Integration

A practical guide to Flutter testing. Learn the Robot pattern, test hierarchies, and how to write tests that catch real bugs without slowing you down.

Muhammad Nabi Rahmani

Muhammad Nabi Rahmani

Flutter Developer passionate about creating beautiful mobile experiences

Flutter Testing That Actually Works: From Unit to Integration

Most Flutter testing tutorials show you how to test a counter app. Real apps have async providers, database dependencies, theme extensions, and complex state machines. Here's how to test those.

The Testing Pyramid for Flutter

Build tests bottom-up. Each layer depends on the one below:

PhaseWhat to TestHow
Domain entitiesComputed properties, copyWith, edge casesPure Dart unit tests, no mocks
Data sourcesAPI converters, mappers, DB operationsUnit tests with mocked SDKs
ServicesBusiness logic, validation, calculationsUnit tests with mocked repositories
ControllersState transitions, action methodsProviderContainer with mocked services
PagesUI rendering, user interactions, statesWidget tests with ProviderScope overrides

Infrastructure First: mocks.dart and test_data.dart

Before writing a single test, set up two files that every test will use.

Centralized Mocks

// test/src/mocks.dart
import 'package:mocktail/mocktail.dart';

class MockDisciplineRepository extends Mock implements DisciplineRepository {}
class MockDisciplineService extends Mock implements DisciplineService {}
class MockJournalService extends Mock implements JournalService {}
class MockAnalyticsService extends Mock implements AnalyticsService {}

// State listener for verifying state transitions
class Listener<T> extends Mock {
  void call(T? previous, T next);
}

One file, all mocks. Never scatter mock classes across test files.

Factory Functions for Test Data

// test/src/test_data.dart
UserProgress makeProgress({
  int currentStreak = 0,
  DateTime? startDate,
  Map<String, bool>? completedTasks,
}) {
  return UserProgress(
    currentStreak: currentStreak,
    startDate: startDate ?? DateTime.now(),
    completedTasks: completedTasks ?? {},
  );
}

JournalEntry makeJournalEntry({int dayNumber = 1, String mood = 'good'}) {
  return JournalEntry(dayNumber: dayNumber, mood: mood, note: '');
}

Every test entity has a make*() factory with sensible defaults. Override only what the test cares about. Never construct entities inline.

Service Tests: The Core

Service tests are the most valuable. They test your business logic with mocked dependencies:

void main() {
  setUpAll(() {
    registerFallbackValue(makeProgress()); // Required for any()
  });

  late MockDisciplineRepository mockRepository;
  late DisciplineService service;

  setUp(() {
    mockRepository = MockDisciplineRepository();
    service = DisciplineService(mockRepository);
  });

  group('calculateCurrentDay', () {
    test('returns 1 on start date', () {
      final startDate = DateTime.now();
      final progress = makeProgress(startDate: startDate);
      expect(service.calculateCurrentDay(progress), 1);
    });

    test('returns 5 after 4 days', () {
      final startDate = DateTime.now().subtract(const Duration(days: 4));
      final progress = makeProgress(startDate: startDate);
      expect(service.calculateCurrentDay(progress), 5);
    });
  });

  group('toggleTask', () {
    test('saves progress with updated task', () async {
      when(() => mockRepository.saveProgress(any()))
          .thenAnswer((_) async {});

      await service.toggleTaskCompletion(1, 0);

      verify(() => mockRepository.saveProgress(any())).called(1);
    });
  });
}

Three rules for service tests:

  1. setUpAll with registerFallbackValue for every custom type used with any()
  2. setUp creates fresh mocks for each test
  3. Date-relative tests: always use DateTime.now().subtract() - never hardcode dates

Controller Tests: State Transitions

Controllers use ProviderContainer with overrides:

void main() {
  late MockJournalService mockService;

  setUp(() {
    mockService = MockJournalService();
  });

  ProviderContainer makeContainer() {
    final container = ProviderContainer(
      overrides: [
        journalServiceProvider.overrideWithValue(mockService),
      ],
    );
    addTearDown(container.dispose); // CRITICAL - never skip
    return container;
  }

  test('save calls service and updates state', () async {
    when(() => mockService.saveEntry(any(), any(), any()))
        .thenAnswer((_) async {});
    when(() => mockService.getEntry(1))
        .thenAnswer((_) async => makeJournalEntry());

    final container = makeContainer();
    await container.read(journalEntryControllerProvider(1).future);

    await container.read(
      journalEntryControllerProvider(1).notifier,
    ).save('content', 'mood');

    verify(() => mockService.saveEntry(1, 'content', 'mood')).called(1);
  });
}

The makeContainer() pattern with addTearDown(container.dispose) prevents provider leaks across tests.

Widget Tests: The Tricky Part

Widget tests require theme extensions and provider overrides:

testWidgets('TodayContent shows tasks section', (tester) async {
  SharedPreferences.setMockInitialValues({});
  final prefs = await SharedPreferences.getInstance();

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        sharedPreferencesProvider.overrideWith((_) => Future.value(prefs)),
        isPremiumProvider.overrideWith((_) => Future.value(false)),
        disciplineServiceProvider.overrideWithValue(FakeDisciplineService()),
      ],
      child: MaterialApp(
        theme: ThemeData(extensions: [AppColors.dark()]),
        home: const Scaffold(body: TodayContent(state: testState)),
      ),
    ),
  );

  await tester.pump(); // Let async providers resolve

  expect(find.text("Today's Focus"), findsOneWidget);
});

Common gotchas:

  • SharedPreferences.setMockInitialValues({}) must come before getInstance()
  • Theme extensions are required if your widgets use context.colors.*
  • await tester.pump() after pumpWidget to resolve async providers

The Robot Pattern: No Duplicate Finders

Robots encapsulate all find.byXxx calls so test files read like user stories:

class TodayRobot {
  TodayRobot(this.tester);
  final WidgetTester tester;

  Future<void> tapTask(int index) async {
    final finder = find.byType(TaskTile).at(index);
    await tester.tap(finder);
    await tester.pumpAndSettle();
  }

  void expectTodaysFocus() {
    expect(find.text("Today's Focus"), findsOneWidget);
  }

  void expectTaskCompleted(int index) {
    final tile = tester.widget<TaskTile>(find.byType(TaskTile).at(index));
    expect(tile.isCompleted, isTrue);
  }
}

// Usage in tests:
testWidgets('toggle task marks it complete', (tester) async {
  final r = Robot(tester);
  await r.pumpApp();

  r.today.expectTodaysFocus();
  r.today.expectTaskCount(3);
  await r.today.tapTask(0);
  r.today.expectTaskCompleted(0);
});

Write the robot once, reuse it for widget tests, integration tests, AND golden tests. Zero finder duplication.

Timer-Based Tests with fake_async

Controllers that use Timer or Future.delayed need controlled time:

import 'package:fake_async/fake_async.dart';

test('auto-clears celebration after 3 seconds', () {
  fakeAsync((async) {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    final controller = container.read(celebrationControllerProvider.notifier);
    controller.celebrate(CelebrationEvent.dayComplete);

    expect(container.read(celebrationControllerProvider), isNotNull);

    async.elapse(const Duration(seconds: 3));

    expect(container.read(celebrationControllerProvider), isNull);
  });
});

Mock vs Fake: When to Use Which

TypeWhenExample
Mock (mocktail)Unit tests - precise control with when/verifyMockDisciplineService
Fake (implements)Controller/widget tests - realistic behaviorFakeDisciplineService

Mocks are for verifying that something was called. Fakes are for providing realistic behavior without the real dependency.

Error Handling Tests

Test that your sealed exception hierarchy works end-to-end:

test('PlanLoadException has correct metadata', () {
  final e = PlanLoadException(details: 'file not found');
  expect(e.severity, ErrorSeverity.error);
  expect(e.category, ErrorCategory.storage);
  expect(e.code, 'plan-load-failed');
  expect(e, isA<AppException>());
});

testWidgets('shows dialog for AppException', (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: ErrorDialogTrigger(
      asyncValue: AsyncValue.error(PlanLoadException(), StackTrace.current),
    ),
  ));
  await tester.pumpAndSettle();
  expect(find.text('Plan Unavailable'), findsOneWidget);
});

testWidgets('silently dismisses PurchaseCancelledException', (tester) async {
  // ... same setup
  expect(find.byType(AlertDialog), findsNothing);
});

The Gotcha Reference Card

ProblemSolution
MissingStubError on any()Add registerFallbackValue in setUpAll
Provider leaks between testsAdd addTearDown(container.dispose)
Widget needs themeAdd ThemeData(extensions: [...])
Async providers not resolvingAdd await tester.pump() after pumpWidget
Date tests break next dayUse DateTime.now().subtract(), never hardcode
Dialog error during buildUse addPostFrameCallback to defer dialog

Start Here

  1. Create mocks.dart and test_data.dart
  2. Write unit tests for your most important service
  3. Add controller tests for your main state provider
  4. Add widget tests for your most complex screen
  5. Introduce the Robot pattern when you have 3+ widget tests

Testing isn't about 100% coverage. It's about testing the code that breaks. Business logic in services, state transitions in controllers, and user flows in widgets. Test those well, and your app will be remarkably stable.

Share:

Keep Reading

More articles you might enjoy

© 2026 Mohammad Nabi RahmaniBuilt with Next.js & Tailwind