Flutter State Management: Beyond Packages (Part 4)
In the final part of this mini-series, we’ll focus on testing our state management components to ensure they work reliably and efficiently.

A Quick Recap
In the first part of this mini-series on Flutter state management, we covered understanding Flutter’s built-in state management tools using StatefulWidget
, setState
and InheritedWidget
. We also covered Flutter’s observer pattern implementations using ChangeNotifier
, ValueNotifier
and ValueListenableBuilder
.
In the second part we covered reactive programming with streams using StreamBuilder
and StreamController
, combining and transforming complex streams, and discussed choosing the right solution.
In the third part we covered building a complete custom state management solution that’s lightweight, type-safe, and scalable. The article is accompanied by a non-trivial flutter example project. This practical example demonstrates all the concepts discussed in this article series, from the core StateStore
class to optimized UI rebuilds with selectors.
The Final Part
Now, in Part 4, we’ll focus on testing our state management components to ensure they work reliably and efficiently.
Testing is often an overlooked aspect of state management, but it’s crucial for building robust applications. In this article, we’ll cover strategies for testing each layer of our custom state management solution, from individual stores to widget integration.
Why Test State Management?
Before diving into the specifics, let’s understand why testing state management is particularly important:
- State Logic Complexity: State management often contains complex business logic that must work correctly.
- UI Reliability: Broken state management leads directly to broken UIs.
- Maintainability: Good tests allow you to refactor with confidence.
- Debuggability: Well-tested state management makes isolating issues easier.
Testing the StateStore Class
Let’s start with the core of our solution: the StateStore
class. This class is responsible for holding state and notifying listeners when the state changes.
Basic State Testing
test('should initialize with initial state', () {
final store = StateStore<int>(42);
expect(store.state, 42);
});
This test verifies that our StateStore
correctly initializes with the provided initial state.
Testing State Updates
test('should update state and notify listeners', () async {
final store = StateStore<int>(10);
// Create a completer to get notified when a specific value is received
final completer20 = Completer<int>();
final completer25 = Completer<int>();
// Listen to the store stream
final subscription = store.stream.listen((value) {
if (value == 20) completer20.complete(value);
if (value == 25) completer25.complete(value);
});
// Verify initial state directly
expect(store.state, 10);
// Update the state
store.update(20);
// Wait for the value with timeout
final value20 = await completer20.future.timeout(
Duration(seconds: 5),
onTimeout: () => -1, // Use a sentinel value on timeout
);
expect(value20, 20, reason: 'Stream should emit 20 after update');
// Update with a function
store.updateWith((current) => current + 5);
// Wait for the next value with timeout
final value25 = await completer25.future.timeout(
Duration(seconds: 5),
onTimeout: () => -1, // Use a sentinel value on timeout
);
expect(value25, 25, reason: 'Stream should emit 25 after updateWith');
// Clean up
subscription.cancel();
store.dispose();
});
This test verifies that our StateStore
correctly updates state and notifies listeners through its stream. Note the use of Completer
to handle the asynchronous nature of streams.
Testing Complex State Objects
test('should handle complex state objects', () {
final initialState = TestState(name: 'Initial', count: 0);
final store = StateStore<TestState>(initialState);
expect(store.state.name, 'Initial');
expect(store.state.count, 0);
// Update with a new state object
store.update(TestState(name: 'Updated', count: 1));
expect(store.state.name, 'Updated');
expect(store.state.count, 1);
// Update with a reducer function
store.updateWith((state) => state.copyWith(count: state.count + 1));
expect(store.state.name, 'Updated');
expect(store.state.count, 2);
store.dispose();
});
This test verifies that our StateStore
works correctly with complex state objects, supporting both direct updates and reducer functions.
Testing the StateManager
The StateManager
is responsible for registering and retrieving state stores by type. Let’s test its functionality.
Testing Registration and Retrieval
test('should register and retrieve state stores by type', () {
final manager = StateManager();
// Register different types of state stores
final counterStore = StateStore<int>(0);
final userStore = StateStore<UserState>(UserState(id: '1', name: 'Test User'));
final settingsStore = StateStore<SettingsState>(SettingsState(isDarkMode: false));
manager.register<int>(counterStore);
manager.register<UserState>(userStore);
manager.register<SettingsState>(settingsStore);
// Retrieve stores and verify they are the correct instances
expect(manager.get<int>(), counterStore);
expect(manager.get<UserState>(), userStore);
expect(manager.get<SettingsState>(), settingsStore);
// Verify the state is correctly stored
expect(manager.get<int>().state, 0);
expect(manager.get<UserState>().state.name, 'Test User');
expect(manager.get<SettingsState>().state.isDarkMode, false);
// Clean up
manager.dispose();
});
This test verifies that our StateManager
correctly registers and retrieves state stores by their type.
Testing Error Handling
test('should throw error when retrieving unregistered state store', () {
final manager = StateManager();
expect(
() => manager.get<String>(),
throwsA(isA<FlutterError>()),
);
// Clean up
manager.dispose();
});
This test verifies that our StateManager
throws an appropriate error when trying to retrieve a state store that hasn’t been registered.
Testing Disposal
test('should dispose all registered state stores', () {
final manager = StateManager();
// Create state stores
final counterStore = StateStore<int>(0);
final userStore = StateStore<UserState>(UserState(id: '1', name: 'Test User'));
manager.register<int>(counterStore);
manager.register<UserState>(userStore);
// Dispose all state stores via manager
manager.dispose();
// Try to use the state stores after disposal - this should fail
expect(
() => counterStore.update(5),
throwsStateError,
);
expect(
() => userStore.update(UserState(id: '2', name: 'New User')),
throwsStateError,
);
});
This test verifies that our StateManager
correctly disposes of all registered state stores when it’s disposed.
Testing Widget Integration
Now let’s test how our state management components integrate with Flutter widgets. This is crucial for ensuring that our UI correctly reacts to state changes.
Testing StateProvider
testWidgets('StateProvider should provide stores to descendants', (WidgetTester tester) async {
// Create a state manager with test state stores
final stateManager = StateManager()
..register<int>(StateStore<int>(42))
..register<String>(StateStore<String>('test'));
// Setup a counter to track how many times our finder function is called
int finderCallCount = 0;
// Build a test widget tree
await tester.pumpWidget(
MaterialApp(
home: StateProvider(
stateManager: stateManager,
child: Builder(
builder: (context) {
finderCallCount++;
// Get state stores from context
final intStore = StateProvider.of<int>(context);
final stringStore = StateProvider.of<String>(context);
return Column(
children: [
Text('Int value: ${intStore.state}'),
Text('String value: ${stringStore.state}'),
],
);
},
),
),
),
);
// Verify the state stores are correctly provided and accessible
expect(find.text('Int value: 42'), findsOneWidget);
expect(find.text('String value: test'), findsOneWidget);
expect(finderCallCount, 1, reason: 'Builder should have been called once during initial build');
});
This test verifies that our StateProvider
correctly provides stores to descendant widgets through the BuildContext
.
Testing StateConsumer
testWidgets('StateConsumer should rebuild when state changes', (WidgetTester tester) async {
// Create a state manager with a counter store
final counterStore = StateStore<int>(0);
final stateManager = StateManager()..register<int>(counterStore);
// Build a test widget tree with StateConsumer
await tester.pumpWidget(
MaterialApp(
home: StateProvider(
stateManager: stateManager,
child: Scaffold(
body: Center(
child: StateConsumer<int>(
builder: (context, count) {
return Text('Count: $count', key: ValueKey('counter-text'));
},
),
),
),
),
),
);
// Verify initial state
expect(find.text('Count: 0'), findsOneWidget);
// Update the state
counterStore.update(5);
// Wait for the widget to rebuild
await tester.pumpAndSettle();
// Verify the widget was rebuilt with the new state
expect(find.text('Count: 5'), findsOneWidget);
// Update again with updateWith
counterStore.updateWith((count) => count + 3);
// Wait for the widget to rebuild
await tester.pumpAndSettle();
// Verify the widget was rebuilt again
expect(find.text('Count: 8'), findsOneWidget);
});
This test verifies that our StateConsumer
correctly rebuilds when the state changes.
Testing StateSelector
// Setup a test state class
class TestState {
final int counter;
final String name;
TestState({required this.counter, required this.name});
TestState copyWith({int? counter, String? name}) {
return TestState(
counter: counter ?? this.counter,
name: name ?? this.name,
);
}
}
testWidgets('StateSelector should only rebuild when selected value changes', (WidgetTester tester) async {
// Create variables to track builds
int counterBuilds = 0;
int nameBuilds = 0;
// Create a key for each widget to ensure they're properly identified
final counterKey = GlobalKey();
final nameKey = GlobalKey();
// Create a state store with the test state
final store = StateStore<TestState>(TestState(counter: 0, name: 'initial'));
final stateManager = StateManager()..register<TestState>(store);
// Build a test widget with two selectors
await tester.pumpWidget(
MaterialApp(
home: StateProvider(
stateManager: stateManager,
child: Column(
children: [
// Selector that only cares about the counter
StateSelector<TestState, int>(
key: counterKey,
selector: (state) => state.counter,
builder: (context, counter) {
counterBuilds++;
return Text('Counter: $counter', key: ValueKey('counter-text'));
},
),
// Selector that only cares about the name
StateSelector<TestState, String>(
key: nameKey,
selector: (state) => state.name,
builder: (context, name) {
nameBuilds++;
return Text('Name: $name', key: ValueKey('name-text'));
},
),
],
),
),
),
);
// Verify initial state
expect(find.text('Counter: 0'), findsOneWidget);
expect(find.text('Name: initial'), findsOneWidget);
expect(counterBuilds, 1);
expect(nameBuilds, 1);
// Update only the counter
store.updateWith((state) => state.copyWith(counter: 5));
await tester.pumpAndSettle();
// Verify only the counter widget rebuilt
expect(find.text('Counter: 5'), findsOneWidget);
expect(find.text('Name: initial'), findsOneWidget);
expect(counterBuilds, 2);
expect(nameBuilds, 1);
// Update only the name
store.updateWith((state) => state.copyWith(name: 'updated'));
await tester.pumpAndSettle();
// Verify only the name widget rebuilt
expect(find.text('Counter: 5'), findsOneWidget);
expect(find.text('Name: updated'), findsOneWidget);
expect(counterBuilds, 2);
expect(nameBuilds, 2);
});
This test verifies that our StateSelector
optimizes rebuilds by only triggering them when the selected portion of state changes.
Testing State Logic in Isolation
Besides testing our state management components directly, it’s also important to test the business logic contained within our state. Here’s an example of testing a more complex state class:
group('CartState', () {
test('should calculate correct total', () {
final product1 = Product(id: '1', name: 'Product 1', price: 10.0);
final product2 = Product(id: '2', name: 'Product 2', price: 20.0);
final cartItems = [
CartItem(product: product1, quantity: 2),
CartItem(product: product2, quantity: 3),
];
final state = CartState(items: cartItems);
// 2 * 10.0 + 3 * 20.0 = 20.0 + 60.0 = 80.0
expect(state.total, 80.0);
});
test('should calculate correct item count', () {
final product1 = Product(id: '1', name: 'Product 1', price: 10.0);
final product2 = Product(id: '2', name: 'Product 2', price: 20.0);
final cartItems = [
CartItem(product: product1, quantity: 2),
CartItem(product: product2, quantity: 3),
];
final state = CartState(items: cartItems);
// 2 + 3 = 5
expect(state.itemCount, 5);
});
test('should detect if product is in cart', () {
final product1 = Product(id: '1', name: 'Product 1', price: 10.0);
final product2 = Product(id: '2', name: 'Product 2', price: 20.0);
final product3 = Product(id: '3', name: 'Product 3', price: 30.0);
final cartItems = [
CartItem(product: product1, quantity: 2),
CartItem(product: product2, quantity: 3),
];
final state = CartState(items: cartItems);
expect(state.hasProduct(product1), isTrue);
expect(state.hasProduct(product2), isTrue);
expect(state.hasProduct(product3), isFalse);
});
});
These tests verify that our CartState
correctly calculates totals, item counts, and detects products in the cart.
Testing Asynchronous Operations
Many state management operations involve asynchronous code, such as API calls. Let’s see how to test these:
test('should load products asynchronously', () async {
final mockApiService = MockApiService();
final mockProducts = [
Product(id: '1', name: 'Product 1', price: 10.0),
Product(id: '2', name: 'Product 2', price: 20.0),
];
// Setup the mock to return our test products
when(mockApiService.getProducts()).thenAnswer((_) async => mockProducts);
// Create a state store with initial state
final store = StateStore<ProductState>(ProductState());
// Function that updates state based on API result
Future<void> loadProducts() async {
// Set loading state
store.updateWith((state) => state.copyWith(isLoading: true));
try {
final products = await mockApiService.getProducts();
store.updateWith((state) => state.copyWith(
products: products,
isLoading: false,
));
} catch (e) {
store.updateWith((state) => state.copyWith(
error: e.toString(),
isLoading: false,
));
}
}
// Initial state should show loading as false
expect(store.state.isLoading, isFalse);
expect(store.state.products, isEmpty);
// Start loading products
final loadFuture = loadProducts();
// State should immediately reflect loading
expect(store.state.isLoading, isTrue);
// Wait for the load to complete
await loadFuture;
// Verify the final state
expect(store.state.isLoading, isFalse);
expect(store.state.products, equals(mockProducts));
expect(store.state.error, isNull);
// Verify the mock was called
verify(mockApiService.getProducts()).called(1);
});
This test verifies that our asynchronous product loading logic correctly updates state at each step of the process.
State Management Example Tests
The test suite for our custom state management solution created in Part 3 provides comprehensive coverage across both unit tests and widget tests to ensure the system’s reliability.
The state_test.dart
file contains unit tests that verify the core functionality of the StateStore
and StateManager
classes. It tests store initialization, state updates, stream notifications, complex state handling, and store registration/retrieval. The tests confirm that state updates propagate correctly between different references to the same store and that proper cleanup occurs during disposal to prevent memory leaks.
The state_widgets_test.dart
file focuses on widget testing to verify that the UI components (StateProvider
, StateConsumer
, and StateSelector
) behave as expected. These tests ensure that state changes properly trigger UI rebuilds, that StateSelector
only rebuilds when its selected value changes (optimizing performance), and that proper error messages are displayed when developers make common mistakes. The tests also verify that resources are cleaned up when widgets are removed from the tree.
Together, these tests demonstrate the robustness of our lightweight custom state management solution, confirming that it provides the core functionality needed for most Flutter applications without the complexity of third-party packages.
You can explore these tests in the code repository.
Best Practices for Testing State Management
Based on the examples above, here are some best practices for testing state management in Flutter:
- Test Each Layer Separately: Test individual state stores, the state manager, and widget integration separately.
- Use Mock Dependencies: For services or repositories that your state depends on, use mocks to control their behavior during tests.
- Test Edge Cases: Don’t just test the happy path. Test error conditions, empty states, and boundary conditions.
- Test State Transitions: Verify that state transitions correctly from one state to another.
- Verify UI Updates: Make sure your UI components correctly rebuild when state changes.
- Test Optimization: Verify that optimizations like selectors work correctly to prevent unnecessary rebuilds.
- Clean Up Resources: Always dispose of state stores, subscriptions, and other resources in your test teardown.
- Use Timeouts for Async Tests: Prevent tests from hanging indefinitely by using timeouts for asynchronous operations.
Resources for Further Learning
To deepen your understanding of reactive programming in Flutter:
- The state management example code repository we built in part 3 of this mini-series: https://github.com/dartfoundry/state_management_example
- Flutter’s official documentation on state management: https://flutter.dev/docs/development/data-and-backend/state-mgmt/intro
- The Dart streams documentation: https://dart.dev/tutorials/language/streams
- Flutter’s widget lifecycle documentation: https://api.flutter.dev/flutter/widgets/State-class.html
- Dive deeper into
InheritedWidget
by reading the source code: https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/inherited_model.dart
By mastering these foundational concepts, you’ll not only be better equipped to use existing state management packages effectively, but you’ll also have the knowledge to customize them or create your own solutions when needed.
Conclusion
In this article, we’ve explored comprehensive testing strategies for our custom state management solution. By testing each component thoroughly, from individual stores to the complete widget integration, we ensure that our state management solution works reliably and efficiently.
Testing state management might seem like extra work, but it’s an investment that pays off in the long run. With a well-tested state management solution, you can refactor with confidence, quickly identify issues, and ensure a smooth user experience.
Remember, the goal of testing isn’t to achieve 100% code coverage, but to verify that your state management behaves correctly under various conditions and edge cases. Focus on testing the critical parts of your state logic, especially those that directly impact your user interface.
In the end, a well-tested state management solution is a key component of a robust, maintainable Flutter application.
Putting this series together has reaffirmed my long-held view that most third-party state management solutions tend to be either over-engineered or unnecessarily complex. I usually prefer building a custom solution because it minimizes my reliance on external packages and gives me full visibility into how everything works.
Third-party code can sometimes be frustrating to debug or even understand, and occasionally, just the naming conventions alone add needless cognitive overhead. Time and again, I find that rolling my own solution delivers exactly what I need, without adding to technical debt.
I hope this series has been useful. If it has please give a clap or better still send me a message!
This has been Part 4 of our “Flutter State Management: Beyond Packages” series. Don’t forget to check out Part 1 for an introduction to Flutter’s built-in state management, Part 2 for reactive programming with streams, and Part 3 for building a complete custom state management solution.