Capstone: Real-World Flutter Project

Testing: Unit, Widget & Integration Tests

16 min Lesson 9 of 10

Testing: Unit, Widget & Integration Tests

A production-quality Flutter application is not complete without a robust test suite. Flutter provides three complementary testing layers: unit tests for business logic, widget tests for UI components, and integration tests for end-to-end user flows. Mastering all three lets you ship with confidence and refactor fearlessly.

Unit Tests — Testing Use Cases and Repositories

Unit tests verify the smallest isolated pieces of logic — use cases, repositories, mappers, and utility functions — without touching the Flutter framework or any real external service. The standard package is flutter_test (already in dev_dependencies); use mockito or mocktail to replace real dependencies with fakes.

A typical capstone project has a clean-architecture shape: Repository interface → Repository implementation → Use case. Unit-test the use case against a mock repository so your test never hits a database or network.

Unit Test: AuthUseCase with a Mock Repository

// test/domain/use_cases/sign_in_use_case_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:my_app/domain/repositories/auth_repository.dart';
import 'package:my_app/domain/use_cases/sign_in_use_case.dart';
import 'package:my_app/domain/entities/user.dart';

class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  late MockAuthRepository mockRepo;
  late SignInUseCase useCase;

  setUp(() {
    mockRepo = MockAuthRepository();
    useCase = SignInUseCase(mockRepo);
  });

  group('SignInUseCase', () {
    const tEmail = 'user@example.com';
    const tPassword = 'secret123';
    final tUser = User(id: '1', email: tEmail, name: 'Test User');

    test('returns User on successful sign-in', () async {
      when(() => mockRepo.signIn(email: tEmail, password: tPassword))
          .thenAnswer((_) async => tUser);

      final result = await useCase(email: tEmail, password: tPassword);

      expect(result, tUser);
      verify(() => mockRepo.signIn(email: tEmail, password: tPassword))
          .called(1);
    });

    test('rethrows AuthException on failure', () async {
      when(() => mockRepo.signIn(email: tEmail, password: tPassword))
          .thenThrow(AuthException('Invalid credentials'));

      expect(
        () => useCase(email: tEmail, password: tPassword),
        throwsA(isA<AuthException>()),
      );
    });
  });
}
Tip: Keep each test() focused on one behaviour. The setUp() block re-creates fresh mocks before every test, preventing state leakage between cases.

Widget Tests — Verifying Key UI Components

Widget tests render a widget in a headless Flutter environment (no real device needed) and let you interact with it programmatically. Use WidgetTester to pump widgets, tap buttons, enter text, and assert on the widget tree. They run much faster than integration tests and catch regressions in individual components.

Widget Test: LoginForm Renders and Submits

// test/presentation/widgets/login_form_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:my_app/presentation/widgets/login_form.dart';
import 'package:my_app/presentation/providers/auth_provider.dart';

class MockAuthNotifier extends Mock implements AuthNotifier {}

void main() {
  testWidgets('LoginForm shows error when fields are empty', (tester) async {
    await tester.pumpWidget(
      const ProviderScope(
        child: MaterialApp(home: Scaffold(body: LoginForm())),
      ),
    );

    // Tap submit without filling in fields
    await tester.tap(find.widgetWithText(ElevatedButton, 'Sign In'));
    await tester.pump();

    expect(find.text('Email is required'), findsOneWidget);
    expect(find.text('Password is required'), findsOneWidget);
  });

  testWidgets('LoginForm calls signIn with entered credentials',
      (tester) async {
    String? capturedEmail;
    String? capturedPassword;

    await tester.pumpWidget(
      ProviderScope(
        child: MaterialApp(
          home: Scaffold(
            body: LoginForm(
              onSubmit: (email, password) {
                capturedEmail = email;
                capturedPassword = password;
              },
            ),
          ),
        ),
      ),
    );

    await tester.enterText(
        find.byKey(const Key('emailField')), 'user@example.com');
    await tester.enterText(
        find.byKey(const Key('passwordField')), 'secret123');
    await tester.tap(find.widgetWithText(ElevatedButton, 'Sign In'));
    await tester.pump();

    expect(capturedEmail, 'user@example.com');
    expect(capturedPassword, 'secret123');
  });
}
Note: Always call await tester.pumpAndSettle() after triggering animations or navigation. Use await tester.pump() for a single frame advance when you want precise control over async timing.

Integration Tests — GoRouter Auth & Feature Flow

Integration tests run on a real device or emulator and drive the full application stack, including navigation. With GoRouter, the router itself is part of the widget tree, so you wrap the app with a real ProviderScope and real (or fake) providers, then simulate the complete user journey.

Place integration tests in integration_test/ (not test/) and use the integration_test package. Run them with flutter test integration_test/ or flutter drive.

Integration Test: Full Auth + Home Navigation Flow

// integration_test/auth_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:my_app/main.dart' as app;
import 'package:my_app/presentation/providers/auth_provider.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Authentication and main feature flow', () {
    testWidgets('User can sign in and reach the home feed', (tester) async {
      // Boot the real app with a fake auth override
      app.main();
      await tester.pumpAndSettle();

      // Expect to land on the login screen (GoRouter redirect)
      expect(find.text('Sign In'), findsOneWidget);

      // Fill credentials
      await tester.enterText(
          find.byKey(const Key('emailField')), 'demo@example.com');
      await tester.enterText(
          find.byKey(const Key('passwordField')), 'Demo1234!');

      // Submit
      await tester.tap(find.widgetWithText(ElevatedButton, 'Sign In'));
      await tester.pumpAndSettle(const Duration(seconds: 3));

      // GoRouter should have redirected to /home after successful auth
      expect(find.text('Welcome'), findsOneWidget);
      expect(find.byType(BottomNavigationBar), findsOneWidget);

      // Navigate to the Tasks feature tab
      await tester.tap(find.byIcon(Icons.task_alt));
      await tester.pumpAndSettle();

      expect(find.text('My Tasks'), findsOneWidget);
    });

    testWidgets('Invalid credentials show error snackbar', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      await tester.enterText(
          find.byKey(const Key('emailField')), 'bad@example.com');
      await tester.enterText(
          find.byKey(const Key('passwordField')), 'wrong');
      await tester.tap(find.widgetWithText(ElevatedButton, 'Sign In'));
      await tester.pumpAndSettle(const Duration(seconds: 3));

      // Should stay on login and show an error message
      expect(find.text('Sign In'), findsOneWidget);
      expect(find.byType(SnackBar), findsOneWidget);
    });
  });
}
Warning: Integration tests are slower and require a connected device or emulator. Do not put them in your test/ folder — the integration_test/ directory tells Flutter to use the IntegrationTestWidgetsFlutterBinding instead of the standard test binding.

Structuring Your Test Suite

A well-organised capstone project keeps tests mirroring the source tree:

  • test/domain/ — unit tests for use cases, entities, value objects
  • test/data/ — unit tests for repository implementations (with mock data sources)
  • test/presentation/ — widget tests for screens and reusable widgets
  • integration_test/ — end-to-end flows that exercise the real router and real (or seeded) providers
Tip: Aim for the testing pyramid: many fast unit tests at the base, a moderate number of widget tests in the middle, and a small set of critical integration tests at the top. This balance keeps your CI pipeline fast while still catching regressions at every layer.

Summary

Flutter's three-tier testing strategy gives you complete coverage of a production app. Unit tests verify business logic in isolation using mocks, running in milliseconds. Widget tests exercise UI components headlessly, catching rendering and interaction bugs without a device. Integration tests drive the real app — including GoRouter redirects and provider state — to validate complete user journeys. Together they form a safety net that lets the capstone project evolve with confidence.