Unit Test #
Unit tests are the most basic, fastest, and most numerous testing layer in your testing pyramid. As the name suggests, unit tests are designed to test the smallest “unit” of your code — like a standalone function, a method in a class, a data Repository, or a state manager (state notifier) — in isolation. This full isolation means unit tests run directly on the Dart VM without loading the Flutter graphics engine, without touching the real internet network system, and without reading physical database files on the device’s disk.
Because they run in a purely memory-based isolated environment, unit tests can execute in milliseconds. This speed provides an instant feedback loop when you modify code. When a unit test fails, you can immediately identify which logic line is problematic precisely. In this guide, we’ll thoroughly discuss unit test writing techniques in Flutter, from the AAA (Arrange-Act-Assert) pattern, advanced mocking techniques using Mocktail, pure function testing, to Riverpod and BLoC state management testing.
Setup & Dependencies #
To start writing unit tests in Flutter, you don’t need to add many external libraries because Dart and Flutter’s built-in testing dependencies are automatically configured when you create a new Flutter project. However, to make creating mock objects easier declaratively without writing manual mock code or triggering code generators, you’re highly recommended to use the Mocktail package.
Add the following dependencies to the dev_dependencies section of your pubspec.yaml file:
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.4
bloc_test: ^9.1.7 # Required if you use Cubit/BLoC for state management
After adding those packages, run the flutter pub get command in the project terminal to download the libraries.
Unit Test Anatomy & the AAA (Arrange-Act-Assert) Pattern #
Writing well-structured tests makes it easier to re-read test code later. The ideal unit test writing structure follows the AAA (Arrange, Act, Assert) pattern:
- Arrange: The stage to prepare all initial test conditions. This scenario includes creating the class instance to be tested, instantiating mock dependency objects, and defining mock behaviors (stubbing) using the
when()method. - Act: The stage to execute the actual method or function being tested (System Under Test / SUT).
- Assert: The stage to match the execution results (from the Act stage) with your expected values using the
expect()function, and ensure dependencies are called correctly using theverify()method.
Here’s the complete unit test anatomy structure on a Repository class:
// test/features/products/data/repositories/product_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:flutter_app/features/products/data/repositories/product_repository_impl.dart';
import 'package:flutter_app/features/products/data/models/product_model.dart';
// 1. Define mock classes using Mocktail
class MockProductRemoteDataSource extends Mock implements ProductRemoteDataSource {}
class MockProductLocalDataSource extends Mock implements ProductLocalDataSource {}
void main() {
// group: Groups together test series with similar contexts
group('ProductRepositoryImpl Testing', () {
late ProductRepositoryImpl repository;
late MockProductRemoteDataSource mockRemote;
late MockProductLocalDataSource mockLocal;
// setUp: Automatically run before EACH individual test runs
setUp(() {
mockRemote = MockProductRemoteDataSource();
mockLocal = MockProductLocalDataSource();
// Initialize the SUT by injecting mock dependencies
repository = ProductRepositoryImpl(
remoteDataSource: mockRemote,
localDataSource: mockLocal,
);
});
// tearDown: Runs after EACH test finishes (great for cleaning up memory)
tearDown(() {
// Resource cleanup scenarios if needed
});
test('Must return the product list from the local cache if data is available', () async {
// ==========================================
// ARRANGE (Condition Preparation)
// ==========================================
final dummyCacheList = [
ProductModel(id: '1', name: 'Laptop Pro', price: 15000000.0),
];
// Stubbing: Determine the mock local data source behavior
when(() => mockLocal.getCachedProducts()).thenAnswer((_) async => dummyCacheList);
// ==========================================
// ACT (Action Execution)
// ==========================================
final result = await repository.getProductsList();
// ==========================================
// ASSERT (Result Verification)
// ==========================================
expect(result, equals(dummyCacheList));
// Verify that the local cache is truly accessed once
verify(() => mockLocal.getCachedProducts()).called(1);
// Verify that the internet (remote) is never accessed at all
verifyNever(() => mockRemote.fetchProducts());
});
});
}
Unit Test Data Flow Architecture #
To understand the data processing flow between the unit test app, the System Under Test (SUT), and the mock dependency, observe the flow diagram below. This diagram illustrates how the AAA (Arrange-Act-Assert) pattern interacts in RAM memory during testing.
graph TD
Test["Unit Test App"] -->|1. Setup Mock & Container| Env["Test Environment (ProviderContainer / Cubit)"]
Test -->|"2. Stubbing (when/thenAnswer)"| Mock["Mock Dependency (Mocktail)"]
Env -->|"3. Trigger Action Logic (Act)"| System["System Under Test (SUT)"]
System -->|4. Call Dependency| Mock
Mock -->|5. Return Simulated Value| System
System -->|6. Emit New State / Return Value| Env
Env -->|"7. Verify & Match (Assert/Expect)"| TestThrough the workflow above, your unit tests are free from disk I/O interactions and real internet networks. The presence of Mock ensures you only test the SUT’s internal logic without being affected by failures in outer layers.
Mocking & Stubbing Techniques with Mocktail #
The Mocktail library works using Dart’s runtime reflection feature, so you don’t need to run the time-consuming build_runner command to create mock classes.
Here’s a guide to advanced stubbing and verification techniques using Mocktail:
1. Fallback Value Registration #
If a method of your mock class accepts arguments in the form of custom objects (not primitive data types like String or int), you must register a fallback value in the setUpAll() method. This is needed so Mocktail knows what default value to give if there’s an arbitrary argument match using the any() matcher.
void main() {
setUpAll(() {
// Register an empty object instantiation as fallback
registerFallbackValue(ProductModel(id: '', name: '', price: 0.0));
});
// Test group series...
}
2. Various Stubbing Methods (when) #
You can determine mock return values based on your testing scenarios:
final mockRepository = MockProductRepository();
// A. thenReturn: Returns a value synchronously (non-Future)
when(() => mockRepository.dbVersion).thenReturn(1);
// B. thenAnswer: Returns a value asynchronously (Future / Stream)
when(() => mockRepository.getProductsList())
.thenAnswer((_) async => <ProductModel>[]);
// C. thenThrow: Simulates an error / Exception occurrence
when(() => mockRepository.deleteProduct(any()))
.thenThrow(Exception('Failed to delete data in the binary database'));
// D. Stubbing based on specific arguments
when(() => mockRepository.getProductById('PROD-123'))
.thenAnswer((_) async => ProductModel(id: 'PROD-123', name: 'Mouse', price: 50000.0));
3. Call Verification Techniques (verify) #
After the action runs, you must verify inter-class interactions to ensure there are no unwanted stealth function calls:
// Ensure the method is called exactly once
verify(() => mockRepository.getProductsList()).called(1);
// Ensure a method with any argument is called at least once
verify(() => mockRepository.deleteProduct(any())).called(greaterThanOrEqualTo(1));
// Ensure a specific method is never called at all
verifyNever(() => mockRepository.clearDatabase());
// Check the method call order strictly
verifyInOrder([
() => mockRepository.checkSessionValidity(),
() => mockRepository.getProductsList(),
]);
Testing Pure Functions #
Pure functions are functions with deterministic properties: if given the same input, they always produce the same output without changing program state outside themselves (no side effects). Testing pure functions is the easiest type of test to write because you don’t need mock objects at all.
Let’s look at an example implementation of a store discount calculator class:
// lib/features/checkout/domain/helpers/discount_calculator.dart
class DiscountCalculator {
static double calculateNetPrice(double originalPrice, double discountPercentage) {
if (originalPrice < 0 || discountPercentage < 0 || discountPercentage > 100) {
throw ArgumentError('Invalid price input or discount percentage');
}
final double discountAmount = originalPrice * (discountPercentage / 100);
return originalPrice - discountAmount;
}
static double applyPromoCode(double totalAmount, String? promoCode) {
if (promoCode == null) return totalAmount;
return switch (promoCode.toUpperCase()) {
'DISKON10' => totalAmount * 0.90,
'DISKON50' => totalAmount * 0.50,
'POTONGAN20K' => totalAmount >= 50000 ? totalAmount - 20000 : totalAmount,
_ => totalAmount,
};
}
}
Here’s the complete unit test to verify all possible conditions (edge cases) of the pure functions above:
// test/features/checkout/domain/helpers/discount_calculator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_app/features/checkout/domain/helpers/discount_calculator.dart';
void main() {
group('DiscountCalculator Testing', () {
group('calculateNetPrice', () {
test('Must calculate the net price after discount correctly', () {
// Arrange, Act, Assert combined in one line for pure function efficiency
expect(DiscountCalculator.calculateNetPrice(100000.0, 10.0), equals(90000.0));
expect(DiscountCalculator.calculateNetPrice(250000.0, 20.0), equals(200000.0));
});
test('Must return the original price if the discount is 0%', () {
expect(DiscountCalculator.calculateNetPrice(50000.0, 0.0), equals(50000.0));
});
test('Must throw ArgumentError if the discount percentage input is out of bounds', () {
expect(
() => DiscountCalculator.calculateNetPrice(100000.0, -5.0),
throwsA(isA<ArgumentError>()),
);
expect(
() => DiscountCalculator.calculateNetPrice(100000.0, 105.0),
throwsA(isA<ArgumentError>()),
);
});
});
group('applyPromoCode', () {
test('Must cut the price by 10% when using the DISKON10 promo', () {
expect(DiscountCalculator.applyPromoCode(100000.0, 'DISKON10'), equals(90000.0));
});
test('Must cut the price by 20,000 if the total shopping meets the POTONGAN20K promo minimum requirement', () {
// Meets the requirement (>= 50,000)
expect(DiscountCalculator.applyPromoCode(60000.0, 'POTONGAN20K'), equals(40000.0));
// Doesn't meet the requirement (< 50,000)
expect(DiscountCalculator.applyPromoCode(30000.0, 'POTONGAN20K'), equals(30000.0));
});
test('Must return the original price if the promo code is unknown or null', () {
expect(DiscountCalculator.applyPromoCode(100000.0, 'FAKE_CODE'), equals(100000.0));
expect(DiscountCalculator.applyPromoCode(100000.0, null), equals(100000.0));
});
});
});
}
Testing Riverpod AsyncNotifiers via ProviderContainer #
Testing state manager classes is an area where developers often make mistakes by unnecessarily rendering UI widgets. In Riverpod, you can test the logic and state change cycles of AsyncNotifier or Notifier purely as a unit test using ProviderContainer.
This testing pattern simulates how Riverpod manages provider lifecycles in RAM memory without needing interaction with the Flutter widget tree.
Here’s the complete unit test for testing a product AsyncNotifier:
// test/features/products/presentation/providers/product_notifier_test.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:flutter_app/features/products/domain/repositories/product_repository.dart';
import 'package:flutter_app/features/products/presentation/providers/product_notifier.dart';
import 'package:flutter_app/features/products/data/models/product_model.dart';
// Mock repository class
class MockProductRepository extends Mock implements ProductRepository {}
// Mock Listener helper to listen to state changes sequentially
class MockListener<T> extends Mock {
void call(T? previous, T next);
}
void main() {
group('ProductNotifier Testing (Riverpod)', () {
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
});
// Helper to create a ProviderContainer with dependency overrides
ProviderContainer makeContainer() {
final container = ProviderContainer(
overrides: [
// Override the real repository with the mock instance
productRepositoryProvider.overrideWithValue(mockRepository),
],
);
// Ensure the container is cleaned up after the test finishes
addTearDown(container.dispose);
return container;
}
test('Must load product data at initial initialization (build)', () async {
// Arrange
final dummyProductList = [
ProductModel(id: '1', name: 'LCD Monitor', price: 2000000.0),
];
when(() => mockRepository.getProductsList()).thenAnswer((_) async => dummyProductList);
final container = makeContainer();
// Act: Read the Future from the provider to force initialization execution
final List<ProductModel> result = await container.read(productListProvider.future);
// Assert
expect(result, equals(dummyProductList));
verify(() => mockRepository.getProductsList()).called(1);
});
test('Must capture the state change sequence (Loading -> Data)', () async {
// Arrange
final dummyProductList = [
ProductModel(id: '1', name: 'LCD Monitor', price: 2000000.0),
];
when(() => mockRepository.getProductsList()).thenAnswer((_) async => dummyProductList);
final container = makeContainer();
// Create a mock listener
final listener = MockListener<AsyncValue<List<ProductModel>>>();
// Connect the listener to the provider
container.listen(
productListProvider,
listener.call,
fireImmediately: true, // Immediately trigger the first call when listened to
);
// Assert: Verify the initial status must be loading
verify(() => listener(null, const AsyncLoading<List<ProductModel>>())).called(1);
// Wait until the async operation completes
await container.read(productListProvider.future);
// Assert: Verify the final status changes to data
verify(() => listener(
const AsyncLoading<List<ProductModel>>(),
AsyncValue.data(dummyProductList),
)).called(1);
});
test('Must emit AsyncError if the repository call fails', () async {
// Arrange
final exception = Exception('Network problem');
when(() => mockRepository.getProductsList()).thenThrow(exception);
final container = makeContainer();
final listener = MockListener<AsyncValue<List<ProductModel>>>();
container.listen(
productListProvider,
listener.call,
fireImmediately: true,
);
// Wait for the microtask cycle to finish processing error handling
await Future.delayed(Duration.zero);
// Assert
verify(() => listener(
any(that: isA<AsyncLoading>()),
any(that: isA<AsyncError>()),
)).called(1);
});
});
}
The pattern above proves that testing AsyncNotifier using ProviderContainer combined with the MockListener helper is very powerful for verifying your state management logic engine purely at the unit level.
Testing BLoC & Cubit State Managers #
For projects choosing the BLoC (Business Logic Component) or Cubit architecture, the Flutter community provides a very mature testing support library called bloc_test. This library simplifies the event call simulation process (act) and verifies the sequential state emission list (expect).
Here’s an example unit test for testing a product Cubit:
// test/features/products/presentation/cubits/product_cubit_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:flutter_app/features/products/domain/repositories/product_repository.dart';
import 'package:flutter_app/features/products/presentation/cubits/product_cubit.dart';
import 'package:flutter_app/features/products/presentation/cubits/product_state.dart';
import 'package:flutter_app/features/products/data/models/product_model.dart';
class MockProductRepository extends Mock implements ProductRepository {}
void main() {
group('ProductCubit Testing (BLoC/Cubit)', () {
late MockProductRepository mockRepository;
setUp(() {
mockRepository = MockProductRepository();
});
// blocTest: Special helper function for testing BLoC/Cubit lifecycles
blocTest<ProductCubit, ProductState>(
'Must emit the [Loading, Success] state when data fetching succeeds',
// build: Create the Cubit instance to be tested
build: () => ProductCubit(mockRepository),
// setUp: Set the stubbing conditions
setUp: () {
final dummyList = [ProductModel(id: '1', name: 'Mouse', price: 50000.0)];
when(() => mockRepository.getProductsList()).thenAnswer((_) async => dummyList);
},
// act: Choose the action / function triggered by the UI
act: (ProductCubit cubit) => cubit.loadProductsFromDatabase(),
// expect: Declare the sequential state emission expectation list
expect: () => [
const ProductState.loading(),
ProductState.success([ProductModel(id: '1', name: 'Mouse', price: 50000.0)]),
],
// verify: Perform interaction verification after all states are emitted
verify: (ProductCubit cubit) {
verify(() => mockRepository.getProductsList()).called(1);
},
);
blocTest<ProductCubit, ProductState>(
'Must emit the [Loading, Error] state when the database call fails',
build: () => ProductCubit(mockRepository),
setUp: () {
when(() => mockRepository.getProductsList()).thenThrow(Exception('Database corrupted'));
},
act: (ProductCubit cubit) => cubit.loadProductsFromDatabase(),
expect: () => [
const ProductState.loading(),
const ProductState.error('Database corrupted'),
],
);
});
}
Using blocTest saves you from having to write manual stream controller synchronization which can potentially trigger test failures due to timing issues (asynchronous delays).
Flutter Matchers Cheat Sheet #
The Dart testing system provides hundreds of Matchers responsible for comparing actual values with expected values declaratively. Understanding the matcher types will help you write expressive assertions.
Here’s the matcher cheat sheet most frequently used in unit testing:
// ==========================================
// 1. EQUALITY & IDENTITY
// ==========================================
expect(actual, equals(expected)); // Compare value equality
expect(actual, same(expected)); // Compare memory reference equality (identity equality)
expect(actual, isNull); // Ensure the value is null
expect(actual, isNotNull); // Ensure the value is not null
expect(actual, isTrue); // Ensure the boolean value is true
expect(actual, isFalse); // Ensure the boolean value is false
// ==========================================
// 2. NUMERIC GROUP
// ==========================================
expect(actual, greaterThan(10)); // > 10
expect(actual, lessThanOrEqualTo(100)); // <= 100
expect(actual, inInclusiveRange(1, 5)); // The number is in the range 1 to 5
expect(actual, closeTo(3.14, 0.01)); // Compare doubles with decimal error tolerance
// ==========================================
// 3. STRING SEARCH
// ==========================================
expect(actual, contains('flutter')); // The text contains the substring 'flutter'
expect(actual, startsWith('Prefix')); // The text starts with the word 'Prefix'
expect(actual, endsWith('!')); // The text ends with an exclamation mark '!'
expect(actual, matches(r'^\d{3}$')); // Match the text with a regular expression (Regex)
// ==========================================
// 4. DATA STRUCTURES (COLLECTIONS)
// ==========================================
expect(actualList, hasLength(3)); // The number of items in the list must be exactly 3
expect(actualList, isEmpty); // The list must be empty
expect(actualList, isNotEmpty); // The list must not be empty
expect(actualList, contains('Apple')); // The list contains the item 'Apple'
expect(actualList, containsAll(['A', 'B'])); // The list contains all items A and B
expect(actualMap, containsPair('code', 200)); // The Map has the key pair 'code' valued 200
// ==========================================
// 5. ASYNC & EXCEPTION HANDLING
// ==========================================
// Ensure the function throws a certain type of Exception when run
expect(() => calculator.divide(5, 0), throwsA(isA<UnsupportedError>()));
// Ensure the async Future completes with the expected success value
await expectLater(futureCall, completion(equals('Success')));
// Ensure the Stream emits data in the expected order
await expectLater(streamCall, emitsInOrder([1, 2, 3]));
Summary #
- Speed & Isolation: Unit tests must run purely on the Dart VM in milliseconds without rendering UI components or calling real network APIs.
- AAA Pattern: Always apply the Arrange (prepare instantiation & mocks), Act (call the target function), and Assert (verify results & interactions) pattern.
- Mocktail Without Generators: Use the Mocktail library to instantly stub async methods (
thenAnswer) or synchronous ones (thenReturn) without code generation.- Riverpod Without UI: Use
ProviderContainerandMockListenerto deterministically testAsyncNotifierstate lifecycles at the unit level.- BLoC / Cubit Testing: Leverage the
bloc_testsupport library to safely test Cubit/BLoC to avoid data flow synchronization issues (stream timing issues).- Matcher Usage: Master the assertion cheat sheet like
isA<T>(),throwsA(),completion(), andemitsInOrder()to accurately verify async logic.