Testing: Unit, Widget & Integration Tests
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>()),
);
});
});
}
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');
});
}
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);
});
});
}
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 objectstest/data/— unit tests for repository implementations (with mock data sources)test/presentation/— widget tests for screens and reusable widgetsintegration_test/— end-to-end flows that exercise the real router and real (or seeded) providers
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.