Riverpod Testing: How to mock state with StateNotifierProvider?

791

Example Repository

I was able to successfully mock the state / provider with StateNotifierProvider. I created a standalone repository here with a breakdown: https://github.com/mdrideout/testing-state-notifier-provider

This works without Mockito / Mocktail.

How To

In order to mock your state when you are using StateNotifier and StateNotifierProvider, your StateNotifier class must contain an optional parameter of your state model, with a default value for how your state should initialize. In your test, you can then pass the mock provider with pre-defined state to your test widget, and use the overrides to override with your mock provider.

Details

See repo linked above for full code

The Test Widget

Widget isEvenTestWidget(StateNotifierProvider<CounterNotifier, Counter> mockProvider) {
    return ProviderScope(
      overrides: [
        counterProvider.overrideWithProvider(mockProvider),
      ],
      child: const MaterialApp(
        home: ScreenHome(),
      ),
    );
  }

This test widget for our home screen uses the overrides property of ProviderScope() in order to override the provider used in the widget.

When the home.dart ScreenHome() widget calls Counter counter = ref.watch(counterProvider); it will use our mockProvider instead of the "real" provider.

The isEvenTestWidget() mockProvider argument is the same "type" of provider as counterProvider().

The Test

testWidgets('If count is even, IsEvenMessage is rendered.', (tester) async {
  // Mock a provider with an even count
  final mockCounterProvider =
      StateNotifierProvider<CounterNotifier, Counter>((ref) => CounterNotifier(counter: const Counter(count: 2)));

  await tester.pumpWidget(isEvenTestWidget(mockCounterProvider));

  expect(find.byType(IsEvenMessage), findsOneWidget);
});

In the test, we create a mockProvider with predefined values that we need for testing ScreenHome() widget rendering. In this example, our provider is initialized with the state count: 2.

We are testing that the isEvenMessage() widget is rendered with an even count (of 2). Another test tests that the widget is not rendered with an odd count.

StateNotifier Constructor

class CounterNotifier extends StateNotifier<Counter> {
  CounterNotifier({Counter counter = const Counter(count: 0)}) : super(counter);

  void increment() {
    state = state.copyWith(count: state.count + 1);
  }
}

In order to be able to create a mockProvider with a predefined state, it is important that the StateNotifier (counter_state.dart) constructor includes an optional parameter of the state model. The default argument is how the state should normally initialize. Our tests can optionally provide a specified state for testing which is passed to super().

Share:
791
Matthew Rideout
Author by

Matthew Rideout

I am an app developer. My backends are mainly built with the GraphQL implementation of NestJS, with TypeSCript. My frontends are mainly built with Flutter / Dart. I have extensive career experience with digital marketing, analytics, UX design, and consumer behavior. This allows me to take a holistic approach to app development, to develop great user experiences.

Updated on January 02, 2023

Comments

  • Matthew Rideout
    Matthew Rideout over 1 year

    Some of my widgets have conditional UI that show / hide elements depending on state. I am trying to set up tests that find or do not find widgets depending on state (for example, such as user role). My code example below is stripped down to the basics of one widget and its state, since I cannot seem to get even the most basic implementation of my state architecture to work with mocks.

    When I follow other examples such as the following:

    I am unable to access the .state value in the override array. I also receive the following error when attempting to run the tests. This is the same with mocktail and mockito. I can only access the .notifier value to override (see similar issue in the comments under the answer here: https://stackoverflow.com/a/68964548/8177355)

    I am wondering if anyone can help me or provide example of how one would mock with this particular riverpod state architecture.

    ══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
    The following ProviderException was thrown building LanguagePicker(dirty, dependencies:
    [UncontrolledProviderScope], state: _ConsumerState#9493f):
    An exception was thrown while building Provider<Locale>#1de97.
    
    Thrown exception:
    An exception was thrown while building StateNotifierProvider<LocaleStateNotifier,
    LocaleState>#473ab.
    
    Thrown exception:
    type 'Null' is not a subtype of type '() => void'
    
    Stack trace:
    #0      MockStateNotifier.addListener (package:state_notifier/state_notifier.dart:270:18)
    #1      StateNotifierProvider.create (package:riverpod/src/state_notifier_provider/base.dart:60:37)
    #2      ProviderElementBase._buildState (package:riverpod/src/framework/provider_base.dart:481:26)
    #3      ProviderElementBase.mount (package:riverpod/src/framework/provider_base.dart:382:5)
    ...[hundreds more lines]
    

    Example Code

    Riverpod stuff

    import 'dart:ui';
    
    import 'package:flutter_riverpod/flutter_riverpod.dart';
    import 'package:freezed_annotation/freezed_annotation.dart';
    import 'package:riverpodlocalization/models/locale/locale_providers.dart';
    import 'package:riverpodlocalization/models/persistent_state.dart';
    import 'package:riverpodlocalization/utils/json_local_sync.dart';
    
    import 'locale_json_converter.dart';
    
    part 'locale_state.freezed.dart';
    part 'locale_state.g.dart';
    
    // Fallback Locale
    const Locale fallbackLocale = Locale('en', 'US');
    
    final localeStateProvider = StateNotifierProvider<LocaleStateNotifier, LocaleState>((ref) => LocaleStateNotifier(ref));
    
    @freezed
    class LocaleState with _$LocaleState, PersistentState<LocaleState> {
      const factory LocaleState({
        @LocaleJsonConverter() @Default(fallbackLocale) @JsonKey() Locale locale,
      }) = _LocaleState;
    
      // Allow custom getters / setters
      const LocaleState._();
    
      static const _localStorageKey = 'persistentLocale';
    
      /// Local Save
      /// Saves the settings to persistent storage
      @override
      Future<bool> localSave() async {
        Map<String, dynamic> value = toJson();
        try {
          return await JsonLocalSync.save(key: _localStorageKey, value: value);
        } catch (e) {
          print(e);
          return false;
        }
      }
    
      /// Local Delete
      /// Deletes the settings from persistent storage
      @override
      Future<bool> localDelete() async {
        try {
          return await JsonLocalSync.delete(key: _localStorageKey);
        } catch (e) {
          print(e);
          return false;
        }
      }
    
      /// Create the settings from Persistent Storage
      /// (Static Factory Method supports Async reading of storage)
      @override
      Future<LocaleState?> fromStorage() async {
        try {
          var _value = await JsonLocalSync.get(key: _localStorageKey);
          if (_value == null) {
            return null;
          }
          var _data = LocaleState.fromJson(_value);
          return _data;
        } catch (e) {
          rethrow;
        }
      }
    
      // For Riverpod integrated toJson / fromJson json_serializable code generator
      factory LocaleState.fromJson(Map<String, dynamic> json) => _$LocaleStateFromJson(json);
    }
    
    class LocaleStateNotifier extends StateNotifier<LocaleState> {
      final StateNotifierProviderRef ref;
      LocaleStateNotifier(this.ref) : super(const LocaleState());
    
      /// Initialize Locale
      /// Can be run at startup to establish the initial local from storage, or the platform
      /// 1. Attempts to restore locale from storage
      /// 2. IF no locale in storage, attempts to set local from the platform settings
      Future<void> initLocale() async {
        // Attempt to restore from storage
        bool _fromStorageSuccess = await ref.read(localeStateProvider.notifier).restoreFromStorage();
    
        // If storage restore did not work, set from platform
        if (!_fromStorageSuccess) {
          ref.read(localeStateProvider.notifier).setLocale(ref.read(platformLocaleProvider));
        }
      }
    
      /// Set Locale
      /// Attempts to set the locale if it's in our list of supported locales.
      /// IF NOT: get the first locale that matches our language code and set that
      /// ELSE: do nothing.
      void setLocale(Locale locale) {
        List<Locale> _supportedLocales = ref.read(supportedLocalesProvider);
    
        // Set the locale if it's in our list of supported locales
        if (_supportedLocales.contains(locale)) {
          // Update state
          state = state.copyWith(locale: locale);
    
          // Save to persistence
          state.localSave();
          return;
        }
    
        // Get the closest language locale and set that instead
        Locale? _closestLocale =
            _supportedLocales.firstWhereOrNull((supportedLocale) => supportedLocale.languageCode == locale.languageCode);
        if (_closestLocale != null) {
          // Update state
          state = state.copyWith(locale: _closestLocale);
    
          // Save to persistence
          state.localSave();
          return;
        }
    
        // Otherwise, do nothing and we'll stick with the default locale
        return;
      }
    
      /// Restore Locale from Storage
      Future<bool> restoreFromStorage() async {
        try {
          print("Restoring LocaleState from storage.");
          // Attempt to get the user from storage
          LocaleState? _state = await state.fromStorage();
    
          // If user is null, there is no user to restore
          if (_state == null) {
            return false;
          }
    
          print("State found in storage: " + _state.toJson().toString());
    
          // Set state
          state = _state;
    
          return true;
        } catch (e, s) {
          print("Error" + e.toString());
          print(s);
          return false;
        }
      }
    }
    

    Widget trying to test

    import 'package:flutter/material.dart';
    import 'package:flutter_riverpod/flutter_riverpod.dart';
    import 'package:riverpodlocalization/models/locale/locale_providers.dart';
    import 'package:riverpodlocalization/models/locale/locale_state.dart';
    import 'package:riverpodlocalization/models/locale/locale_translate_name.dart';
    
    class LanguagePicker extends ConsumerWidget {
      const LanguagePicker({Key? key}) : super(key: key);
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        Locale _currentLocale = ref.watch(localeProvider);
        List<Locale> _supportedLocales = ref.read(supportedLocalesProvider);
    
        print("Current Locale: " + _currentLocale.toLanguageTag());
    
        return DropdownButton<Locale>(
            isDense: true,
            value: (!_supportedLocales.contains(_currentLocale)) ? null : _currentLocale,
            icon: const Icon(Icons.arrow_drop_down),
            underline: Container(
              height: 1,
              color: Colors.black26,
            ),
            onChanged: (Locale? newLocale) {
              if (newLocale == null) {
                return;
              }
              print("Selected " + newLocale.toString());
    
              // Set the locale (this will rebuild the app)
              ref.read(localeStateProvider.notifier).setLocale(newLocale);
    
              return;
            },
            // Create drop down items from our supported locales
            items: _supportedLocales
                .map<DropdownMenuItem<Locale>>(
                  (locale) => DropdownMenuItem<Locale>(
                    value: locale,
                    child: Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 8.0),
                      child: Text(
                        translateLocaleName(locale: locale),
                      ),
                    ),
                  ),
                )
                .toList());
      }
    }
    

    Test file

    import 'package:flutter/material.dart';
    import 'package:flutter_riverpod/flutter_riverpod.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:mocktail/mocktail.dart';
    import 'package:riverpodlocalization/models/locale/locale_state.dart';
    import 'package:riverpodlocalization/widgets/language_picker.dart';
    
    class MockStateNotifier extends Mock implements LocaleStateNotifier {}
    
    void main() {
      final mockStateNotifier = MockStateNotifier();
    
      Widget testingWidget() {
        return ProviderScope(
          overrides: [localeStateProvider.overrideWithValue(mockStateNotifier)],
          child: const MaterialApp(
            home: LanguagePicker(),
          ),
        );
      }
    
      testWidgets('Test that the pumpedWidget is loaded with our above mocked state', (WidgetTester tester) async {
        await tester.pumpWidget(testingWidget());
      });
    }