Skip to main content
Back to Blog
FlutterTestingUnit TestsWidget TestsBLoCMockitoDartCI/CD

Flutter Testing: Unit, Widget, and Integration Tests

CoreCodery Team

Get new tutorials every Tuesday

Join developers reading CoreCodery Weekly — Flutter, Cloudflare & Supabase.

No spam. Unsubscribe anytime.

Flutter Testing: Unit, Widget, and Integration Tests

Series: Flutter Deep Dives · Part 4 of 4 Keywords: flutter testing tutorial, flutter unit test BLoC, flutter bloc test, flutter mockito example


Introduction

Clean Architecture promises testability. BLoC promises predictable state. But none of that matters unless you actually write the tests.

This article walks through the complete testing strategy we use in Pill Timer — a medication reminder app built with Flutter Clean Architecture. You'll see real test code from the project, not toy examples, covering every layer of the stack.


The Testing Pyramid in Flutter

Flutter supports three types of tests, each with a different cost/fidelity tradeoff:

          ▲
         /  \
        / E2E \       ← slow, full device, few tests
       /________\
      / Integration\  ← medium speed, real widgets + services
     /______________\
    /   Unit Tests   \ ← fast, isolated, many tests
   /__________________\

| Type | Speed | Isolation | Confidence | |------|-------|-----------|------------| | Unit | Fast | High | Logic only | | Widget | Medium | Medium | UI + logic | | Integration | Slow | Low | Full flows |

Rule of thumb: Most of your tests should be unit tests. Integration tests should cover only the critical happy paths.

In Pill Timer, we mirror `lib/` exactly in `test/`:

lib/features/specific/medication/
  domain/
    entities/medication_entity.dart
    usecases/get_logs_for_date_usecase.dart
  presentation/
    blocs/medication_bloc.dart

test/features/specific/medication/
  domain/
    medication_entity_test.dart
    get_logs_for_date_usecase_test.dart
  presentation/
    medication_bloc_test.dart

Unit Tests — Entities and Use Cases

Testing Entities

Entities are pure Dart — no Flutter, no dependencies. They're the easiest thing to test.

// test/features/specific/medication/domain/medication_entity_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/features/specific/medication/domain/entities/medication_entity.dart';

void main() {
  group('MedicationEntity', () {
    final now = DateTime(2026, 4, 3);

    final medication = MedicationEntity(
      id: 'med-001',
      name: 'Aspirin',
      dosage: '100mg',
      frequency: MedicationFrequency.onceDaily,
      scheduledTimes: const ['08:00'],
      startDate: now,
      createdAt: now,
      updatedAt: now,
    );

    test('has correct default values', () {
      final defaultMed = MedicationEntity(
        id: 'x',
        name: 'Vitamin C',
        dosage: '500mg',
        frequency: MedicationFrequency.onceDaily,
        scheduledTimes: const ['09:00'],
        startDate: now,
        createdAt: now,
        updatedAt: now,
      );

      expect(defaultMed.isActive, isTrue);
      expect(defaultMed.endDate, isNull);
      expect(defaultMed.colorValue, 0xFF4CAF50);
    });

    test('copyWith creates new instance with changed fields', () {
      final updated = medication.copyWith(name: 'Aspirin 200mg', dosage: '200mg');

      expect(updated.name, 'Aspirin 200mg');
      expect(updated.dosage, '200mg');
      expect(updated.id, medication.id);            // unchanged
      expect(updated, isNot(same(medication)));     // new instance
    });
  });
}

Key assertions: - `isTrue` / `isFalse` for booleans - `isNull` / `isNotNull` for optionals - `equals()` for value equality (via Equatable) - `same()` to verify it's a different object reference (immutability check)

Testing Use Cases with a Fake Repository

Use cases depend on repository interfaces. Instead of mocking them with Mockito, we often write a fake — a simple in-memory implementation:

// test/features/specific/medication/domain/get_logs_for_date_usecase_test.dart
class _FakeLogRepository implements MedicationLogRepository {
  final Map<String, List<MedicationLogEntity>> _store = {};

  void seed(DateTime date, List<MedicationLogEntity> logs) {
    _store[_key(date)] = logs;
  }

  String _key(DateTime d) => '${d.year}-${d.month}-${d.day}';

  @override
  Future<List<MedicationLogEntity>> getLogsForDate(DateTime date) async =>
      List.from(_store[_key(date)] ?? []);

  // ... other interface methods with empty impls
}

void main() {
  group('GetLogsForDateUseCase', () {
    late _FakeLogRepository repo;
    late GetLogsForDateUseCase usecase;

    setUp(() {
      repo = _FakeLogRepository();
      usecase = GetLogsForDateUseCase(repo);
    });

    test('returns empty list when no logs for date', () async {
      final result = await usecase(GetLogsForDateParams(DateTime(2026, 4, 1)));
      expect(result, isEmpty);
    });

    test('does not return logs from a different date', () async {
      final dayA = DateTime(2026, 4, 1);
      final dayB = DateTime(2026, 4, 2);
      repo.seed(dayA, [
        MedicationLogEntity(id: 'l1', medicationId: 'm1', takenAt: dayA, scheduledTime: '08:00'),
      ]);

      final result = await usecase(GetLogsForDateParams(dayB));
      expect(result, isEmpty);
    });
  });
}

Fake vs Mock: Fakes are explicit and refactor-safe. Use Mockito when you need to verify *how* a method was called (call count, argument matching), not just *what* it returns.


Using Mockito for Repositories

Add to `pubspec.yaml`:

dev_dependencies:
  mockito: ^5.4.4
  build_runner: ^2.4.9

Generate mocks:

// test/mocks.dart
import 'package:mockito/annotations.dart';
import 'package:my_app/features/specific/medication/domain/repositories/medication_repository.dart';

@GenerateMocks([MedicationRepository])
void main() {}
flutter pub run build_runner build --delete-conflicting-outputs

Use the generated mock:

import 'mocks.mocks.dart';

void main() {
  group('GetMedicationsUseCase', () {
    late MockMedicationRepository mockRepo;
    late GetMedicationsUseCase usecase;

    setUp(() {
      mockRepo = MockMedicationRepository();
      usecase = GetMedicationsUseCase(mockRepo);
    });

    test('returns medications from repository', () async {
      when(mockRepo.getMedications()).thenAnswer((_) async => [
        // ... test data
      ]);

      final result = await usecase();
      expect(result, hasLength(1));
      verify(mockRepo.getMedications()).called(1);
    });
  });
}

BLoC Testing

BLoC testing in Pill Timer uses in-memory Sembast databases — no mocking needed for the persistence layer, which makes tests more realistic.

// test/features/specific/medication/presentation/medication_bloc_test.dart
import 'package:sembast/sembast_memory.dart';

void main() {
  late MedicationService service;
  late MedicationLogService logService;

  setUp(() async {
    final db = await databaseFactoryMemory.openDatabase('bloc_test.db');
    final logDb = await databaseFactoryMemory.openDatabase('bloc_log_test.db');
    service = MedicationService.withDatabase(db);
    logService = MedicationLogService.withDatabase(logDb);
  });

  group('MedicationBloc', () {
    test('initial state is MedicationInitial', () {
      final bloc = _buildBloc(service, logService);
      expect(bloc.state, isA<MedicationInitial>());
      bloc.close();
    });

    test('LoadMedications emits [Loading, Loaded] with empty list', () async {
      final bloc = _buildBloc(service, logService);
      final states = <MedicationState>[];
      final sub = bloc.stream.listen(states.add);

      bloc.add(LoadMedications());
      await Future<void>.delayed(const Duration(milliseconds: 50));

      expect(states[0], isA<MedicationLoading>());
      expect(states[1], isA<MedicationLoaded>());
      expect((states[1] as MedicationLoaded).medications, isEmpty);

      await sub.cancel();
      await bloc.close();
    });

    test('AddMedication adds and reloads medications', () async {
      final bloc = _buildBloc(service, logService);
      final states = <MedicationState>[];
      final sub = bloc.stream.listen(states.add);

      bloc.add(AddMedication(_makeModel('med-001', 'Aspirin')));
      await Future<void>.delayed(const Duration(milliseconds: 100));

      final loaded = states.whereType<MedicationLoaded>().last;
      expect(loaded.medications, hasLength(1));
      expect(loaded.medications.first.name, 'Aspirin');

      await sub.cancel();
      await bloc.close();
    });
  });
}

Pattern: Always `close()` the bloc after the test. Use `whereType<T>().last` to get the most recent state of a specific type — avoids brittle index-based assertions.

Using the `bloc_test` Package

For cleaner BLoC test syntax, add `bloc_test`:

dev_dependencies:
  bloc_test: ^9.1.7
import 'package:bloc_test/bloc_test.dart';

blocTest<MedicationBloc, MedicationState>(
  'emits [Loading, Loaded] when LoadMedications is added',
  build: () => _buildBloc(service, logService),
  act: (bloc) => bloc.add(LoadMedications()),
  expect: () => [
    isA<MedicationLoading>(),
    isA<MedicationLoaded>(),
  ],
);

`blocTest` handles the stream subscription, timing, and bloc lifecycle automatically.


Widget Tests — Testing UI in Isolation

Widget tests render components without a device. They're faster than integration tests and catch UI bugs at the component level.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() {
  testWidgets('MedicationCard shows medication name', (tester) async {
    final medication = MedicationEntity(
      id: 'med-001',
      name: 'Aspirin',
      dosage: '100mg',
      // ...
    );

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: MedicationCard(medication: medication),
        ),
      ),
    );

    expect(find.text('Aspirin'), findsOneWidget);
    expect(find.text('100mg'), findsOneWidget);
  });

  testWidgets('shows loading indicator while BLoC loads', (tester) async {
    final mockBloc = MockMedicationBloc();
    when(() => mockBloc.state).thenReturn(MedicationLoading());

    await tester.pumpWidget(
      BlocProvider<MedicationBloc>.value(
        value: mockBloc,
        child: const MaterialApp(home: MedicationListScreen()),
      ),
    );

    expect(find.byType(CircularProgressIndicator), findsOneWidget);
  });
}

Tips: - Use `tester.pump()` for synchronous state changes; `tester.pumpAndSettle()` for animations - `find.text()`, `find.byType()`, `find.byKey()` are your main finders - Provide `MaterialApp` wrapper — many widgets need Material theme context


Integration Tests — Full App Flows

Integration tests run on a real device or simulator. Use them sparingly — cover only the critical happy paths.

// integration_test/medication_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('user can add a medication', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    // Navigate to add medication
    await tester.tap(find.byKey(const Key('add_medication_fab')));
    await tester.pumpAndSettle();

    // Fill in form
    await tester.enterText(find.byKey(const Key('medication_name_field')), 'Aspirin');
    await tester.enterText(find.byKey(const Key('medication_dosage_field')), '100mg');

    // Save
    await tester.tap(find.byKey(const Key('save_medication_button')));
    await tester.pumpAndSettle();

    // Verify it appears in the list
    expect(find.text('Aspirin'), findsOneWidget);
  });
}

Add the dependency:

dev_dependencies:
  integration_test:
    sdk: flutter

Run on a connected device:

flutter test integration_test/

Running Tests and Coverage

Run all unit + widget tests:

flutter test

Run with coverage:

flutter test --coverage

This generates `coverage/lcov.info`. Convert to HTML:

genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

Aim for 80%+ coverage on domain and data layers. Presentation layer tests (widget tests) are valuable but harder to maintain — focus on critical UI flows.


CI Setup with GitHub Actions

Add this workflow to `.github/workflows/flutter.yml`:

name: Flutter CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.x'
          channel: stable
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Run code generation
        run: flutter pub run build_runner build --delete-conflicting-outputs

      - name: Verify formatting
        run: dart format --output=none --set-exit-if-changed .

      - name: Analyze
        run: flutter analyze

      - name: Run tests with coverage
        run: flutter test --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: coverage/lcov.info

This runs on every PR: dependencies → code generation → formatting → static analysis → tests with coverage. Failing any step blocks the merge.


Summary

| Layer | Test Type | Tool | Example | |-------|-----------|------|---------| | Entities | Unit | flutter_test | `expect(entity.isActive, isTrue)` | | Use Cases | Unit | flutter_test + fake/mock | `expect(result, isEmpty)` | | BLoC | Unit | bloc_test | `blocTest(...)` | | UI Components | Widget | flutter_test | `find.text('Aspirin')` | | Critical Flows | Integration | integration_test | Full E2E flow |

Testing in Clean Architecture is straightforward because each layer has a clear boundary. Entities have zero dependencies. Use cases depend only on interfaces. BLoCs depend only on use cases. Test each layer in isolation and you'll catch bugs before they reach production.


Related Articles

  • [Flutter Clean Architecture Guide](/blog/flutter-clean-architecture) (Article #1) — the architecture this testing strategy is built on
  • [Flutter BLoC Pattern](/blog/flutter-bloc-state-management) (Article #4) — understanding Events, States, and Blocs
  • [How We Built Pill Timer](/blog/pill-timer-flutter-clean-architecture-case-study) (Article #5) — the full case study

*Tags: Flutter, Testing, Unit Tests, Widget Tests, Integration Tests, BLoC, Mockito, Dart, CI/CD, GitHub Actions*

Enjoyed this? Get notified of new posts.

Weekly tutorials on Flutter, Cloudflare & Supabase — free.

No spam. Unsubscribe anytime.