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:
- https://bartvwezel.nl/flutter/flutter-riverpod-testing-example/
- https://stackoverflow.com/a/66122516/8177355
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());
});
}
CodePudding user response:
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()
.