The Developer Who Ships With Confidence
Here’s a confession: early in my career, I spent an entire afternoon writing JSON serialization code for 15 model classes. By hand. Individually.
Never again.
Dart’s code generation ecosystem is one of its most underappreciated superpowers. Once you understand `build_runner`, your models practically write themselves. This blog is for anyone — junior dev or senior architect — who’s tired of writing code that a machine should be writing for them.
The Testing Philosophy
The Three Layers
Flutter best practices recommend 70% unit tests for business logic, 20% widget tests for UI components, and 10% integration tests for critical user flows — the standard testing pyramid adapted for Flutter.
Setup
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.4.4
build_runner: ^2.4.6
integration_test:
sdk: flutter
Unit Tests
Writing Your First Unit Test
// lib/services/cart_service.dart
class CartService {
final List<String> _items = [];
void addItem(String item) {
if (item.isEmpty) throw ArgumentError('Item cannot be empty');
_items.add(item);
}
void removeItem(String item) => _items.remove(item);
int get itemCount => _items.length;
double get total => _items.length * 9.99; // simplified
}
Run: `flutter test test/services/cart_service_test.dart`
Pro tip
// test/services/cart_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/services/cart_service.dart';
void main() {
group('CartService', () {
late CartService cartService;
setUp(() {
cartService = CartService(); // Fresh instance before each test
});
test('starts empty', () {
expect(cartService.itemCount, equals(0));
});
test('adds item correctly', () {
cartService.addItem('Flutter Book');
expect(cartService.itemCount, equals(1));
});
test('throws on empty item name', () {
expect(() => cartService.addItem(''), throwsArgumentError);
});
test('calculates total correctly', () {
cartService.addItem('Item A');
cartService.addItem('Item B');
expect(cartService.total, equals(19.98));
});
test('removes item correctly', () {
cartService.addItem('Item A');
cartService.removeItem('Item A');
expect(cartService.itemCount, equals(0));
});
});
}
Testing Asynchronous Code
// lib/repositories/user_repository.dart
class UserRepository {
final ApiClient _client;
UserRepository(this._client);
Future<User> fetchUser(String id) async {
final data = await _client.get('/users/$id');
return User.fromJson(data);
}
}
// test/repositories/user_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
void main() {
test('fetchUser returns user on success', () async {
final fakeClient = FakeApiClient(); // No mocking library needed for simple cases
final repo = UserRepository(fakeClient);
final user = await repo.fetchUser('123');
expect(user.id, equals('123'));
expect(user.name, equals('Alice'));
});
}
// Simple fake — no library needed
class FakeApiClient implements ApiClient {
@override
Future<Map<String, dynamic>> get(String path) async {
return {'id': '123', 'name': 'Alice', 'email': '[email protected]'};
}
}
Mocking and Dependency Injection
Mockito for Powerful Mocks
// test/repositories/user_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:my_app/clients/api_client.dart';
import 'package:my_app/repositories/user_repository.dart';
import 'user_repository_test.mocks.dart'; // Generated by build_runner
@GenerateMocks([ApiClient]) // Annotation tells build_runner what to mock
void main() {
late MockApiClient mockClient;
late UserRepository repository;
setUp(() {
mockClient = MockApiClient();
repository = UserRepository(mockClient); // Inject mock
});
test('fetchUser returns user when API succeeds', () async {
// Arrange
when(mockClient.get('/users/123')).thenAnswer((_) async => {
'id': '123',
'name': 'Alice',
'email': '[email protected]',
});
// Act
final user = await repository.fetchUser('123');
// Assert
expect(user.name, equals('Alice'));
verify(mockClient.get('/users/123')).called(1); // Verify it was called once
});
test('fetchUser throws when API fails', () async {
// Arrange — simulate network error
when(mockClient.get('/users/999'))
.thenThrow(Exception('Network error'));
// Act & Assert
expect(
() => repository.fetchUser('999'),
throwsException,
);
});
}
Generate mocks: `dart run build_runner build --delete-conflicting-outputs`
Writing Testable Code From Day One
// Untestable — hard-coded dependency
class OrderService {
final _repo = DatabaseOrderRepository(); // Can't swap this!
Future<void> placeOrder(Order order) => _repo.save(order);
}
// Testable — injected dependency
class OrderService {
final OrderRepository _repo; // Abstract interface
OrderService(this._repo); // Injected from outside
Future<void> placeOrder(Order order) => _repo.save(order);
}
Widget Tests
// test/widgets/login_button_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/login_button.dart';
void main() {
testWidgets('LoginButton shows loading when tapped', (tester) async {
bool tapped = false;
// Build the widget
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: LoginButton(
onPressed: () async {
tapped = true;
await Future.delayed(const Duration(seconds: 1));
},
),
),
));
// Find and tap the button
await tester.tap(find.byType(ElevatedButton));
await tester.pump(); // Rebuild after tap
// Assert loading state shows
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(tapped, isTrue);
});
testWidgets('LoginButton displays correct label', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: LoginButton(label: 'Sign In', onPressed: () {}),
),
));
expect(find.text('Sign In'), findsOneWidget);
});
}
Integration Tests
Integration tests test complete app workflows on real or simulated devices. Use them for your most critical user paths — login → checkout → confirmation, for example.
// integration_test/checkout_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();
group('Checkout Flow', () {
testWidgets('complete purchase flow', (tester) async {
app.main(); // Launch the real app
await tester.pumpAndSettle();
// Step 1: Add item to cart
await tester.tap(find.byKey(const Key('add_to_cart_button')));
await tester.pumpAndSettle();
// Step 2: Go to cart
await tester.tap(find.byKey(const Key('cart_icon')));
await tester.pumpAndSettle();
expect(find.text('1 item'), findsOneWidget);
// Step 3: Checkout
await tester.tap(find.byKey(const Key('checkout_button')));
await tester.pumpAndSettle();
expect(find.text('Order Confirmed'), findsOneWidget);
});
});
}
Run on a device: `flutter test integration_test/checkout_flow_test.dart`
Explore project snapshots or discuss custom web solutions.
Code without tests is broken by design.
Thank You for Spending Your Valuable Time
I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Frequently Asked Questions
Flutter supports unit tests (verify business logic and Dart code), widget tests (validate UI components and interactions), and integration tests (test complete app workflows on real or simulated devices).
No. Chasing 100% often means writing meaningless tests for trivial getters. Aim for 80%+ on business logic. Focus coverage on the paths users actually take, error conditions, and edge cases. Meaningful coverage beats high coverage numbers.
Wrap the plugin in your own abstract class, then mock the wrapper. For example, create `abstract class LocationService { Future<Position> getCurrentPosition(); }` and a `GeolocatorLocationService` that uses the plugin. In tests, inject a `MockLocationService`.
TDD works best for business logic (pure Dart). For UI, a "test-after" approach (writing widget tests right after implementing a widget) works well in practice. The goal is testable code and coverage — the exact order matters less than the habit.
Unit and widget tests run fine on CI without any device — they use a virtual engine. Integration tests require a real device or emulator. Tools like Firebase Test Lab, BrowserStack, and GitHub Actions with Android emulator support make integration tests in CI practical.
Comments are closed