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.

Flutter State Management: Beyond Packages (Part 4)
Image created by author

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:

  1. State Logic Complexity: State management often contains complex business logic that must work correctly.
  2. UI Reliability: Broken state management leads directly to broken UIs.
  3. Maintainability: Good tests allow you to refactor with confidence.
  4. 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:

  1. Test Each Layer Separately: Test individual state stores, the state manager, and widget integration separately.
  2. Use Mock Dependencies: For services or repositories that your state depends on, use mocks to control their behavior during tests.
  3. Test Edge Cases: Don’t just test the happy path. Test error conditions, empty states, and boundary conditions.
  4. Test State Transitions: Verify that state transitions correctly from one state to another.
  5. Verify UI Updates: Make sure your UI components correctly rebuild when state changes.
  6. Test Optimization: Verify that optimizations like selectors work correctly to prevent unnecessary rebuilds.
  7. Clean Up Resources: Always dispose of state stores, subscriptions, and other resources in your test teardown.
  8. 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:

  1. The state management example code repository we built in part 3 of this mini-series: https://github.com/dartfoundry/state_management_example
  2. Flutter’s official documentation on state management: https://flutter.dev/docs/development/data-and-backend/state-mgmt/intro
  3. The Dart streams documentation: https://dart.dev/tutorials/language/streams
  4. Flutter’s widget lifecycle documentation: https://api.flutter.dev/flutter/widgets/State-class.html
  5. 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.