🧐

[Flutter]riverpodのStateNotifierProviderのUnitTestでハマったこと

2021/07/14に公開

はじめに

テスト対象のStateNotifierProviderから、テスト対象外のStateNotifierProviderのStateを参照している場合、参照しているStateをmockitoを使用してstubを作成する方法がよくわからなかったので、備忘録として残しておこうと思います。
Riverpodのバージョンが0.13.*以前は、こちらに記載されている方法でStateをオーバライドすればできてたみたいですが、0.14.0以降は、.stateが削除されて、この方法は使えませんでした。。。
記載しているやり方で正解なのか正直自信がないので、他に良いやり方をご存知の方はご教授いただけると嬉しいです!

環境

  • Flutter version 2.2.2
  • Dart version 2.13.3
依存関係
environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

  hooks_riverpod: ^0.14.0+1
  flutter_hooks: ^0.16.0
  freezed_annotation: ^0.14.1
  json_serializable: ^4.1.0
  meta: ^1.3.0
  intl: ^0.17.0
  rxdart: ^0.27.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.0.10
  freezed: ^0.14.2
  build_runner: ^2.0.6

やったこと

テスト対象のStateNotifierProvider

下記がテスト対象の実装コードです。AccountEditStateControllerクラスの初期化処理で、AccountStateを読み込んで、AccountEditStateに値をセットするように実装されています。

テスト対象コード
account_edit_state_controller.dart
/// AccountEditStateController provider
final accountEditStateControlProvider = StateNotifierProvider.autoDispose<
    AccountEditStateController, AccountEditState>(
  (ref) => AccountEditStateController(ref),
);

/// AccountEditStateController class
class AccountEditStateController extends StateNotifier<AccountEditState> {
  AccountEditStateController(this._ref) : super(const AccountEditState()) {
    _initialize();
  }
  final ProviderReference _ref;
  AccountState get _accountState => _ref.read(accountStateControlProvider);

  /// -------------------------------------------------------------
  /// 初期処理
  Future<void> _initialize() async {
    final name = _accountState.name;
    final value = _accountState.age;
    state = state.copyWith(
      nameController: TextEditingController(
        text: _accountState.name,
      ),
      ageController: TextEditingController(
        text: _accountState.age,
      ),
    );
  }
}

テストコード

上記のStateNotifierProviderをテストするために記載したテストコードは下記になります。
AccountEditStateの初期値として、AccountStateの値がセットされているかチェックを行いたいので、when(mockAccountStateController.state).thenReturn(fakeAccountState);の箇所で指定したStateを返すようにStub化を行いました。

テストコード
account_edit_state_controller.test.dart
import 'account_edit_state_controller_test.mocks.dart';

([AccountStateController])
void main() {
  final fakeAccountState = AccountState(
    name: 'testName',
    age: 30,
  );

  group('AccountEditStateControllerの確認', () {
    final mockAccountStateController = MockAccountStateController();

    late ProviderContainer container;

    // 各テスト前にMockをセット
    setUp(() {
      container = ProviderContainer(
        overrides: [
          accountStateControlProvider
              .overrideWithValue(mockAccountStateController),
        ],
      );
    });

    // 各テスト前にMockを初期化
    tearDown(() {
      reset(mockAccountStateController);
    });

    test('AccountEditStateの初期値が正しいこと', () {
      when(mockAccountStateController.state).thenReturn(fakeAccountState);
      final state = container.read(accountEditStateControlProvider);
      // 初期値の確認
      expect(state.nameController?.text, fakeAccountState.name);
      expect(state.ageController?.text, fakeAccountState.age);
    });
}

テスト結果

上記のテストコードを実行すると下記のエラーが吐かれました。

Thrown exception:
MissingStubError: 'addListener'
No stub was found which matches the arguments of this method call:
addListener(Closure: (Object?) => void from Function '_listener@637333131':., {fireImmediately: true})

Add a stub for this method using Mockito's 'when' API, or generate the mock for MockAccountStateController with 'returnNullOnMissingStub: true'.

エラー内容を調べてみると、テスト対象のAccountEditStateControllerAccountStateを読み込んでいる、 _ref.read(accountStateControlProvider)の部分で、addListenerが呼ばれているけど、stubが作成されてないよ〜と言った感じでした。
addListenerのstubを作成して、AccountStateを返せば良いのか!と軽い気持ちでいましたが、これのstubが全然作成できなくて挫折しました。。。
エラーメッセージのreturnNullOnMissingStub: trueをMockクラス作成のオプションに設定して、Mockの再作成なども試してみましたが、このオプションはstubを作成していない場合に、nullを返すようになるだけなので、今度はAccountStatenullになってしまって、こちらもエラーになってしまいました。

最終的にどうやったか

最終的にはテスト対象のAccountEditStateControllerを下記のように変更することで、こっちが意図するAccountStateを渡すことができました。(テストコードは上記から変更なしです。)

最終的なテスト対象コード
account_edit_state_controller.dart
/// AccountEditStateController provider
final accountEditStateControlProvider = StateNotifierProvider.autoDispose<
    AccountEditStateController, AccountEditState>(
  (ref) => AccountEditStateController(ref),
);

/// AccountEditStateController class
class AccountEditStateController extends StateNotifier<AccountEditState> {
  AccountEditStateController(this._ref) : super(const AccountEditState()) {
    _initialize();
  }
  final ProviderReference _ref;
+  AccountState get _accountState => 
+      _ref.read(accountStateControlProvider.notifier).state;
-  AccountState get _accountState => _ref.read(accountStateControlProvider);

  /// -------------------------------------------------------------
  /// 初期処理
  Future<void> _initialize() async {
    final name = _accountState.name;
    final value = _accountState.age;
    state = state.copyWith(
      nameController: TextEditingController(
        text: _accountState.name,
      ),
      ageController: TextEditingController(
        text: _accountState.age,
      ),
    );
  }
}

おわりに

Riverpodはバージョンが0.14以降からStateNotifierProviderの構文が変更になったので、ref.read(accountStateControlProvider)という形でStateを読み込むことができるようになりました。しかし、Stateをスタブ化するためにref.read(accountStateControlProvider.notifier).stateとしなくちゃいけないのは何か腑に落ちないので、おそらく私が方法を見つけられていないだけだと思います。。。
どなたか良い方法をご存知でしたらご教授いただけると嬉しいです!

最後まで読んでいただきありがとうございます!

Discussion