Riverpod 测试:如何使用 StateNotifierProvider 模拟状态?
Riverpod Testing: How to mock state with StateNotifierProvider?
我的一些小部件有条件 UI 可以根据状态显示/隐藏元素。我正在尝试设置根据状态(例如,用户角色)查找或不查找小部件的测试。我下面的代码示例被简化为一个小部件及其状态的基础知识,因为我似乎无法获得状态架构的最基本实现来使用模拟。
当我遵循以下其他示例时:
我无法访问覆盖数组中的 .state
值。我在尝试 运行 测试时也收到以下错误。这与 mocktail 和 mockito 相同。我只能访问要覆盖的 .notifier
值(参见此处答案下评论中的类似问题:)
我想知道是否有人可以帮助我或提供示例说明如何模拟这个特定的 riverpod 状态架构。
══╡ 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]
示例代码
Riverpod 东西
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;
}
}
}
正在尝试测试的小部件
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());
}
}
测试文件
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());
});
}
示例存储库
我能够使用 StateNotifierProvider 成功模拟状态/提供者。我在这里创建了一个带有故障的独立存储库:https://github.com/mdrideout/testing-state-notifier-provider
没有 Mockito / Mocktail 也能工作。
如何
为了在您使用 StateNotifier 和 StateNotifierProvider 时模拟您的状态,您的 StateNotifier class 必须包含状态模型的可选参数,以及状态应如何初始化的默认值。在您的测试中,您可以将具有预定义状态的模拟提供程序传递给您的测试小部件,并使用 overrides
覆盖您的模拟提供程序。
详情
查看上面链接的 repo 以获得完整代码
测试小部件
Widget isEvenTestWidget(StateNotifierProvider<CounterNotifier, Counter> mockProvider) {
return ProviderScope(
overrides: [
counterProvider.overrideWithProvider(mockProvider),
],
child: const MaterialApp(
home: ScreenHome(),
),
);
}
我们主屏幕的这个测试小部件使用 ProviderScope()
的 overrides
属性 来覆盖小部件中使用的提供程序。
当 home.dart ScreenHome()
小部件调用 Counter counter = ref.watch(counterProvider);
时,它将使用我们的 mockProvider
而不是“真正的”提供者。
isEvenTestWidget()
mockProvider 参数与 counterProvider()
提供者的“类型”相同。
测试
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);
});
在测试中,我们创建了一个 mockProvider,其中包含测试 ScreenHome()
小部件渲染所需的预定义值。在此示例中,我们的提供程序使用 state count: 2
.
初始化
我们正在测试 isEvenMessage()
小部件以偶数(2)呈现。另一个测试测试小部件没有以奇数呈现。
StateNotifier 构造函数
class CounterNotifier extends StateNotifier<Counter> {
CounterNotifier({Counter counter = const Counter(count: 0)}) : super(counter);
void increment() {
state = state.copyWith(count: state.count + 1);
}
}
为了能够创建具有预定义状态的 mockProvider,重要的是 StateNotifier (counter_state.dart
) 构造函数包含状态模型的可选参数。 默认参数 是状态通常应该如何初始化。我们的测试可以可选地提供指定的测试状态,该状态将传递给super()
。
我的一些小部件有条件 UI 可以根据状态显示/隐藏元素。我正在尝试设置根据状态(例如,用户角色)查找或不查找小部件的测试。我下面的代码示例被简化为一个小部件及其状态的基础知识,因为我似乎无法获得状态架构的最基本实现来使用模拟。
当我遵循以下其他示例时:
我无法访问覆盖数组中的 .state
值。我在尝试 运行 测试时也收到以下错误。这与 mocktail 和 mockito 相同。我只能访问要覆盖的 .notifier
值(参见此处答案下评论中的类似问题:)
我想知道是否有人可以帮助我或提供示例说明如何模拟这个特定的 riverpod 状态架构。
══╡ 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]
示例代码
Riverpod 东西
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;
}
}
}
正在尝试测试的小部件
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());
}
}
测试文件
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());
});
}
示例存储库
我能够使用 StateNotifierProvider 成功模拟状态/提供者。我在这里创建了一个带有故障的独立存储库:https://github.com/mdrideout/testing-state-notifier-provider
没有 Mockito / Mocktail 也能工作。
如何
为了在您使用 StateNotifier 和 StateNotifierProvider 时模拟您的状态,您的 StateNotifier class 必须包含状态模型的可选参数,以及状态应如何初始化的默认值。在您的测试中,您可以将具有预定义状态的模拟提供程序传递给您的测试小部件,并使用 overrides
覆盖您的模拟提供程序。
详情
查看上面链接的 repo 以获得完整代码
测试小部件
Widget isEvenTestWidget(StateNotifierProvider<CounterNotifier, Counter> mockProvider) {
return ProviderScope(
overrides: [
counterProvider.overrideWithProvider(mockProvider),
],
child: const MaterialApp(
home: ScreenHome(),
),
);
}
我们主屏幕的这个测试小部件使用 ProviderScope()
的 overrides
属性 来覆盖小部件中使用的提供程序。
当 home.dart ScreenHome()
小部件调用 Counter counter = ref.watch(counterProvider);
时,它将使用我们的 mockProvider
而不是“真正的”提供者。
isEvenTestWidget()
mockProvider 参数与 counterProvider()
提供者的“类型”相同。
测试
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);
});
在测试中,我们创建了一个 mockProvider,其中包含测试 ScreenHome()
小部件渲染所需的预定义值。在此示例中,我们的提供程序使用 state count: 2
.
我们正在测试 isEvenMessage()
小部件以偶数(2)呈现。另一个测试测试小部件没有以奇数呈现。
StateNotifier 构造函数
class CounterNotifier extends StateNotifier<Counter> {
CounterNotifier({Counter counter = const Counter(count: 0)}) : super(counter);
void increment() {
state = state.copyWith(count: state.count + 1);
}
}
为了能够创建具有预定义状态的 mockProvider,重要的是 StateNotifier (counter_state.dart
) 构造函数包含状态模型的可选参数。 默认参数 是状态通常应该如何初始化。我们的测试可以可选地提供指定的测试状态,该状态将传递给super()
。