Flutter Testing — Unit Tests, Widget Tests, and Integration Tests
In this tutorial, you will learn about Flutter Testing. We cover key concepts, practical examples, and best practices to help you master this topic.
Flutter testing encompasses unit tests for business logic, widget tests for UI components, and integration tests for complete user flows, all powered by the flutter_test package and test runner.
What Will You Learn
- Setting up tests in a Flutter project
- Writing unit tests for models and services
- Testing widget UI with WidgetTester
- Mocking dependencies with Mockito
- Testing asynchronous code and streams
- Writing integration tests with integration_test
- Test coverage and best practices
Why It Matters
Testing is essential for maintaining app quality as the codebase grows. Flutter's testing framework provides three levels: unit tests (fast, run on the Dart VM), widget tests (moderate speed, run in a simulated Flutter environment), and integration tests (slower, run on a real device or emulator). A well-tested app has fewer regressions, better architecture, and documentation through tests.
Real-World Use
The DodaTech Flutter app has over 500 tests across all three levels. Unit tests cover data models, API services, and state management logic. Widget tests verify that the login screen renders correctly and shows error messages. Integration tests run the complete user journey from login to course completion on real devices in CI.
Learning Path
flowchart LR A[Firebase Integration] --> B[Flutter Testing\nYou are here] B --> C[Flutter Performance] style B fill:#f90,color:#fff
Unit Tests
Unit tests test individual functions, classes, and services without UI:
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/models/user.dart';
import 'package:myapp/services/validation_service.dart';
void main() {
group('User Model', () {
test('fromJson creates User from valid JSON', () {
final json = {
'id': 1,
'name': 'Alice',
'email': 'alice@example.com',
};
final user = User.fromJson(json);
expect(user.id, 1);
expect(user.name, 'Alice');
expect(user.email, 'alice@example.com');
});
test('toJson produces correct JSON', () {
final user = User(id: 1, name: 'Alice', email: 'alice@example.com');
final json = user.toJson();
expect(json['name'], 'Alice');
expect(json['email'], 'alice@example.com');
});
test('two users with same values are equal', () {
final user1 = User(id: 1, name: 'Alice', email: 'alice@test.com');
final user2 = User(id: 1, name: 'Alice', email: 'alice@test.com');
expect(user1, user2);
});
});
group('ValidationService', () {
late ValidationService validator;
setUp(() {
validator = ValidationService();
});
test('valid email returns no error', () {
final error = validator.validateEmail('user@example.com');
expect(error, isNull);
});
test('invalid email returns error message', () {
final error = validator.validateEmail('not-an-email');
expect(error, isNotNull);
expect(error, contains('valid email'));
});
test('empty email returns error', () {
final error = validator.validateEmail('');
expect(error, isNotNull);
});
});
}
Use group() to organize related tests. Use setUp() to initialize test fixtures. Use descriptive test names that explain the expected behavior.
Widget Tests
Widget tests verify UI rendering and interaction:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:myapp/screens/login_screen.dart';
void main() {
group('LoginScreen', () {
testWidgets('renders email and password fields', (tester) async {
await tester.pumpWidget(
MaterialApp(home: LoginScreen()),
);
expect(find.text('Email'), findsOneWidget);
expect(find.text('Password'), findsOneWidget);
expect(find.text('Login'), findsOneWidget);
});
testWidgets('shows error on empty submission', (tester) async {
await tester.pumpWidget(
MaterialApp(home: LoginScreen()),
);
// Tap the login button
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Verify error message appears
expect(find.text('Please enter your email'), findsOneWidget);
});
testWidgets('accepts valid input', (tester) async {
await tester.pumpWidget(
MaterialApp(home: LoginScreen()),
);
// Enter email
await tester.enterText(
find.widgetWithText(TextField, 'Email'),
'user@example.com',
);
// Enter password
await tester.enterText(
find.widgetWithText(TextField, 'Password'),
'password123',
);
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Verify no error
expect(find.text('Please enter your email'), findsNothing);
});
});
}
pumpWidget renders the widget. find locates widgets by text, type, or key. tap, enterText, and drag simulate user interaction. pumpAndSettle waits for animations to complete.
Testing Stateful Widgets
Test widgets with state changes:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
class CounterWidget extends StatefulWidget {
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_count', key: Key('count_display')),
ElevatedButton(
onPressed: () => setState(() => _count++),
key: Key('increment_button'),
child: Text('Increment'),
),
],
);
}
}
void main() {
testWidgets('counter starts at zero', (tester) async {
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
expect(find.text('Count: 0'), findsOneWidget);
});
testWidgets('increment increases count', (tester) async {
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
await tester.tap(find.byKey(Key('increment_button')));
await tester.pump();
expect(find.text('Count: 1'), findsOneWidget);
});
testWidgets('multiple increments work', (tester) async {
await tester.pumpWidget(MaterialApp(home: CounterWidget()));
for (var i = 0; i < 5; i++) {
await tester.tap(find.byKey(Key('increment_button')));
}
await tester.pump();
expect(find.text('Count: 5'), findsOneWidget);
});
}
Use keys to identify widgets uniquely. After tap, call pump() (one frame) or pumpAndSettle() (all animations). Multiple taps require multiple pump calls.
Mocking with Mockito
Mock dependencies to isolate the code under test:
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:myapp/services/api_service.dart';
import 'package:myapp/services/auth_service.dart';
// Generate mocks: flutter pub run build_runner build
@GenerateMocks([ApiService])
import 'auth_service_test.mocks.dart';
void main() {
late AuthService authService;
late MockApiService mockApiService;
setUp(() {
mockApiService = MockApiService();
authService = AuthService(apiService: mockApiService);
});
group('AuthService', () {
test('login returns user on success', () async {
when(mockApiService.login(
email: anyNamed('email'),
password: anyNamed('password'),
)).thenAnswer((_) async => User(id: 1, name: 'Alice'));
final user = await authService.login('alice@test.com', 'pass123');
expect(user.name, 'Alice');
verify(mockApiService.login(
email: 'alice@test.com',
password: 'pass123',
)).called(1);
});
test('login throws exception on failure', () async {
when(mockApiService.login(
email: anyNamed('email'),
password: anyNamed('password'),
)).thenThrow(Exception('Network error'));
expect(
() => authService.login('bad@test.com', 'wrong'),
throwsException,
);
});
});
}
Mockito generates mock classes at compile time. Use when().thenAnswer() for async methods and when().thenReturn() for sync methods. Use verify() to assert that methods were called with expected arguments.
Testing Streams and Futures
Test async code with fake async utilities:
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
class StockService {
final StreamController<double> _controller = StreamController<double>.broadcast();
Stream<double> get priceStream => _controller.stream;
void updatePrice(double price) => _controller.add(price);
Future<double> fetchLatestPrice() async {
await Future.delayed(Duration(seconds: 1));
return 150.25;
}
void dispose() => _controller.close();
}
void main() {
group('StockService async tests', () {
test('fetchLatestPrice returns expected value', () async {
final service = StockService();
final price = await service.fetchLatestPrice();
expect(price, 150.25);
});
test('priceStream emits values', () async {
final service = StockService();
final emitted = <double>[];
service.priceStream.listen((price) => emitted.add(price));
service.updatePrice(100.0);
service.updatePrice(101.5);
// Give the stream time to emit
await Future.delayed(Duration.zero);
expect(emitted, [100.0, 101.5]);
service.dispose();
});
});
}
For testing with fake time, use fakeAsync from flutter_test or the clock package to control time without real delays.
Integration Tests
Integration tests run on real devices or emulators:
// In integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:myapp/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('App E2E Tests', () {
testWidgets('full login flow', (tester) async {
app.main();
await tester.pumpAndSettle();
// Verify we are on login screen
expect(find.text('Welcome Back'), findsOneWidget);
// Fill in credentials
await tester.enterText(
find.widgetWithText(TextField, 'Email'),
'test@example.com',
);
await tester.enterText(
find.widgetWithText(TextField, 'Password'),
'password123',
);
// Tap login
await tester.tap(find.text('Sign In'));
await tester.pumpAndSettle();
// Verify we navigated to home screen
expect(find.text('Home'), findsOneWidget);
});
testWidgets('navigation through tabs', (tester) async {
app.main();
await tester.pumpAndSettle();
// Login first
await tester.enterText(
find.widgetWithText(TextField, 'Email'),
'test@example.com',
);
await tester.enterText(
find.widgetWithText(TextField, 'Password'),
'password123',
);
await tester.tap(find.text('Sign In'));
await tester.pumpAndSettle();
// Navigate to profile tab
await tester.tap(find.text('Profile'));
await tester.pumpAndSettle();
expect(find.text('My Profile'), findsOneWidget);
});
});
}
Run integration tests with:
flutter test integration_test/app_test.dart
flutter test integration_test/app_test.dart -d chrome # for web
Integration tests require integration_test package and flutter_driver dependency.
Test Coverage
Measure and enforce test coverage:
# Generate coverage report
flutter test --coverage
# View coverage as HTML
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
Aim for 80%+ coverage on business logic. Widget and integration test coverage is harder to quantify but equally important.
Common Mistakes
Testing implementation details instead of behavior: Tests should verify what the code does, not how it does it. Refactoring should not break tests.
Not using
pumpAndSettlefor async operations: After triggering an async operation (tap, navigation), callpumpAndSettle()to wait for all animations and async work.Forgetting to mock external dependencies: Real API calls, database access, and Firebase services fail in tests. Always mock them.
Creating overly complex test setup: If a test requires many lines of setup, the code under test may have too many dependencies. Refactor to simplify.
Not running tests in CI: Tests are only valuable if they run consistently. Add
flutter testandflutter test integration_testto your CI pipeline.
Practice Questions
- What is the difference between
pump(),pumpWidget(), andpumpAndSettle()? - How does Mockito generate mock classes?
- Why should widget tests avoid testing implementation details?
- What is the purpose of
IntegrationTestWidgetsFlutterBinding? - Challenge: Write widget tests for a login screen that has email, password fields, a login button, and a "Forgot Password" link. Test: initial render, empty field validation, invalid email format, valid input submission, and navigation to forgot password screen.
Mini Project
Write comprehensive tests for a todo app:
- Unit tests for Todo model (fromJson, toJson, toggle completion)
- Unit tests for TodoService (add, delete, update, search)
- Widget tests for TodoListScreen (renders list, empty state, add todo dialog)
- Widget tests for TodoItem widget (shows title, checkbox toggles, swipe to delete)
- Mock database service for widget tests
- Integration test for full flow: add todo, mark complete, delete
FAQ
What is Next
Now that you understand testing, learn about performance optimization. Proceed to Flutter Performance for profiling, widget rebuild minimization, and rendering optimization. Then explore Flutter Native Channels for platform-specific functionality.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro