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