Solved: Riverpod Testing: How to mock state with StateNotifierProvider?

Question

Asked by Matthew R on November 17, 2021 (source).

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());
  });
}

Answer

Question answered by Matthew R (source).

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().

Video Answers on YouTube

DART FLUTTER MOCKITO TESTING
SHARE: