Unit testing in Flutter: a practical guide
By Charlin Joe · 13 February 2025
Unit tests give you confidence that individual pieces of your code do what they claim. In Flutter, a unit test runs in the Dart VM with no device, no widgets, and no Flutter framework — just pure Dart. That makes them fast: a suite of 500 tests runs in seconds.
What to unit test
Unit tests are ideal for:
- Business logic — calculating totals, validating inputs, transforming data
- Repositories and services — with mocked data sources
- BLoC/Cubit/ViewModel state machines — with mocked repositories
- Utilities and pure functions — formatters, parsers, validators
Not ideal for: widgets (use widget tests), platform interactions (use integration tests), database queries against a real database (use integration tests).
Setup
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0 # Or mockito
Create test files mirroring your lib/ structure:
lib/domain/services/order_service.dart
test/domain/services/order_service_test.dart
Writing your first test
import 'package:flutter_test/flutter_test.dart';
double calculateTotal(List<double> prices, {double taxRate = 0.08}) {
final subtotal = prices.fold(0.0, (sum, price) => sum + price);
return subtotal * (1 + taxRate);
}
void main() {
group('calculateTotal', () {
test('returns sum with default tax rate', () {
expect(calculateTotal([10.0, 20.0]), closeTo(32.4, 0.01));
});
test('returns 0 for empty list', () {
expect(calculateTotal([]), 0.0);
});
test('applies custom tax rate', () {
expect(calculateTotal([100.0], taxRate: 0.20), closeTo(120.0, 0.01));
});
test('handles single item', () {
expect(calculateTotal([50.0]), closeTo(54.0, 0.01));
});
});
}
Mocking with mocktail
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
// The interface
abstract class OrderRepository {
Future<List<Order>> fetchOrders();
Future<Order> getOrder(String id);
}
// The mock — one line
class MockOrderRepository extends Mock implements OrderRepository {}
// The class under test
class OrderService {
OrderService(this._repo);
final OrderRepository _repo;
Future<List<Order>> getPendingOrders() async {
final all = await _repo.fetchOrders();
return all.where((o) => o.status == 'pending').toList();
}
}
void main() {
late MockOrderRepository mockRepo;
late OrderService service;
setUp(() {
mockRepo = MockOrderRepository();
service = OrderService(mockRepo);
});
group('OrderService.getPendingOrders', () {
test('returns only pending orders', () async {
when(() => mockRepo.fetchOrders()).thenAnswer((_) async => [
Order(id: '1', status: 'pending'),
Order(id: '2', status: 'shipped'),
Order(id: '3', status: 'pending'),
]);
final result = await service.getPendingOrders();
expect(result, hasLength(2));
expect(result.every((o) => o.status == 'pending'), isTrue);
});
test('returns empty list when no orders', () async {
when(() => mockRepo.fetchOrders()).thenAnswer((_) async => []);
expect(await service.getPendingOrders(), isEmpty);
});
test('propagates repository errors', () async {
when(() => mockRepo.fetchOrders())
.thenThrow(const NetworkException());
expect(
() => service.getPendingOrders(),
throwsA(isA<NetworkException>()),
);
});
});
}
Testing BLoC
dev_dependencies:
bloc_test: ^9.1.0
class OrderCubit extends Cubit<OrderState> {
OrderCubit(this._repo) : super(const OrderInitial());
final OrderRepository _repo;
Future<void> fetchOrders() async {
emit(const OrderLoading());
try {
final orders = await _repo.fetchOrders();
emit(OrderLoaded(orders));
} on NetworkException {
emit(const OrderError('No internet connection'));
}
}
}
void main() {
group('OrderCubit', () {
late MockOrderRepository mockRepo;
setUp(() => mockRepo = MockOrderRepository());
blocTest<OrderCubit, OrderState>(
'emits [Loading, Loaded] on success',
build: () => OrderCubit(mockRepo),
setUp: () {
when(() => mockRepo.fetchOrders()).thenAnswer((_) async => [
Order(id: '1', status: 'pending'),
]);
},
act: (cubit) => cubit.fetchOrders(),
expect: () => [
const OrderLoading(),
isA<OrderLoaded>().having(
(s) => s.orders,
'orders',
hasLength(1),
),
],
);
blocTest<OrderCubit, OrderState>(
'emits [Loading, Error] on network failure',
build: () => OrderCubit(mockRepo),
setUp: () {
when(() => mockRepo.fetchOrders()).thenThrow(const NetworkException());
},
act: (cubit) => cubit.fetchOrders(),
expect: () => [
const OrderLoading(),
const OrderError('No internet connection'),
],
);
});
}
Testing async code
test('completes within 5 seconds', () async {
await expectLater(
service.fetchOrders(),
completes,
).timeout(const Duration(seconds: 5));
});
test('emits values in order', () async {
final stream = service.watchOrders();
expect(stream, emitsInOrder([isEmpty, isNotEmpty]));
});
test('stream closes cleanly', () {
expect(service.watchOrders(), emitsDone);
});
Useful matchers
expect(value, isA<Order>()); // Type check
expect(value, isA<Order>().having((o) => o.id, 'id', '1')); // Type + property
expect(list, hasLength(3)); // Collection size
expect(list, contains(isA<Order>())); // Collection membership
expect(value, closeTo(3.14, 0.01)); // Floating point
expect(fn, throwsA(isA<NetworkException>())); // Exception type
expect(fn, throwsA(predicate<Exception>((e) => e.message.contains('404')))); // Exception detail
expect(future, completion(equals(expectedValue))); // Future result
Running tests
# All tests
flutter test
# Specific file
flutter test test/domain/order_service_test.dart
# With coverage
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html
# Watch mode (re-run on file changes)
flutter test --watch
Common pitfalls
Testing implementation details. Test behaviour (given input X, output is Y), not implementation (this method was called N times with these arguments). Verify interactions only when they're part of the contract (e.g., "must call save after fetch").
Mocking everything. Don't mock value objects, simple data classes, or pure functions. Only mock things that cross system boundaries: APIs, databases, device sensors.
Not testing error cases. The happy path is easy to write. The error paths (network timeout, empty response, malformed JSON) are where production bugs live. Test them explicitly.
Sign in to like, dislike, or report.