FlutterでRiverpodとMockitoを使ってProviderのテストしてみた

2024.04.15

こんにちは。
モダンアプリコンサル部の坂本です。

前回の記事ではFlutterでMockitoを使用した依存関係のあるクラスの単体テストを紹介しました。
今回はRirverpod Generatorを用いて製造されたNotifierクラスについて、Providerを用いたテストを記述してみようと思います。

Riverpod Generatorの使い方などはこちらの記事を参考にしてみてください。

Riverpod Generatorに適用させる

前回の構成をRiverpodのNotifierでステート管理を行う構成に変更し、Riverpod Generatorに適用させます。
DIもRiverpodで行うよう変更しています。

login_controller.dart

part 'login_controller.g.dart';

@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
  return AuthRepository();
}

@riverpod
class LoginController extends _$LoginController {
  @override
  User? build() => null;

  Future login({required String loginId, required String password}) async {
    // LoginId、Passwordの入力チェック
    if (loginId.isNotEmpty && password.isNotEmpty) {
      state = await ref.read(authRepositoryProvider).login(loginId: loginId, password: password);
    } else {
      throw Exception("ログインID、パスワードは必須です");
    }
  }
}

上記でBuild Runnerを走らせると、login_controller.g.dartが生成されます。
Riverpod Generatorではbuildメソッドの戻り値に応じてNotifierを自動選択してくれますが、今回のケースではAutoDisposeNotifierが生成されました。

テストコード

先に結果から。
記述したテストコードが以下になります。
(長いので一部省略)

login_controller_test.dart

abstract interface class ValueChangeListener {
  void call<T>(T previous, T next);
}

@GenerateNiceMocks([MockSpec<AuthRepository>(), MockSpec<ValueChangeListener>()])
void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  final authRepository = MockAuthRepository();
  final listener = MockValueChangeListener();

  setUp(() {
    reset(authRepository);
    reset(listener);
  });

  ProviderContainer createContainer() =>
      ProviderContainer(
          overrides: [
            authRepositoryProvider.overrideWithValue(authRepository),
          ]
      );

  group("login", () {
    test("success", () async {
      when(
        authRepository.login(
          loginId: argThat(equals("tanaka@example.com"), named: "loginId"),
          password: argThat(equals("8peJuzJ*naBd"), named: "password"),
        ),
      ).thenAnswer((_) async => const User(
        loginId: "tanaka@example.com",
        name: "田中 太郎",
      ));

      final container = createContainer();
      container.listen(loginControllerProvider, listener.call);

      final controller = container.read(loginControllerProvider.notifier);

      await controller.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd");

      verify(
        listener.call(
          isNull,
          isA<User>()
            .having((p0) => p0.loginId, "loginId", "tanaka@example.com")
            .having((p0) => p0.name, "name", "田中 太郎"),
        ),
      ).called(1);

      verify(
        authRepository.login(
          loginId: "tanaka@example.com",
          password: "8peJuzJ*naBd",
        ),
      ).called(1);
    });

    ...
  });
}

簡単な説明

前回と比較するとずいぶんと様変わりした印象です。
今回の変更によってテストの流れが以下のようになりました。

  1. (必要に応じて)スタブを定義
  2. ProviderContainerを生成(依存関係のoverride)
  3. Providerをテスト用のListenerでlisten
  4. Notifier(Controller)の処理を発火
  5. Listenerに期待通りの値が流れてきているかをverifyで評価

2でProviderContainerというオブジェクトが登場した点、テスト方法として3でListenerを登録し、5で「Listenerに期待通りの値が流れてきているか」を評価している点が大きく異なります。 この2点について簡単に説明していきます。

ProviderContainer

ProviderContainer createContainer() =>
    ProviderContainer(
        overrides: [
          authRepositoryProvider.overrideWithValue(authRepository),
        ]
    );

ProviderContainerはProviderの状態を監視するオブジェクトです。

通常、ProviderContainerは暗黙的に生成されるため、明示的な利用をしなくても良いのですが、テストにおいてはプロバイダの状態はテストごとにリセットしたいため明示的に生成します。

また、ProviderContainerはProviderの挙動をoverrideすることができます。
ここではauthRepositoryProviderMockAuthRepositoryを返却するようoverrideしています。

Listener

RiverpodのProviderにはListenerを通してステートの変更を監視するlistenメソッドが用意されています。
今回のテストではこの仕組みを利用し、ValueChangeListenerというインターフェースを定義の上モックを生成し、モックの呼び出しを評価しています。

テストに用いるListenerのinterfaceです。

abstract interface class ValueChangeListener {
  void call<T>(T previous, T next);
}

listener.callloginControllerProviderのステートの変更を購読しています。

container.listen(loginControllerProvider, listener.call);

評価したい処理を発火し、listener.callに対する呼び出しを評価しています。

await controller.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd");

verify(
  listener.call(
    isNull,
    isA<User>()
      .having((p0) => p0.loginId, "loginId", "tanaka@example.com")
      .having((p0) => p0.name, "name", "田中 太郎"),
  ),
).called(1);

少し回りくどい印象がありますね。
controller.stateを直接評価するのではダメなのか?という疑問が出てきます。
ここまでのケースだとそれでも評価可能ですが、以下に記述するAsyncNotifierの評価を行う場合、評価しきれない部分が出てきます。

AsyncNotifierの評価

ここまでのケースではNotifierに初期値は不要でした。
しかし、実際の開発においては初期値をWebAPIなどから取得するケースも多いのではないでしょうか。
Riverpodを用いた開発ではこういったケースの多くにはAsyncNotifierが使用されます。

async_auth_repository.dart

class AsyncAuthRepository {
  Future<User?> verifyLoginUser() async {
    // セキュアストレージからアクセストークンとか拾って、ユーザー情報取得までやってる風
    await Future.delayed(const Duration(microseconds: 500));

    return const User(loginId: "sakamoto@example.com", name: "坂本 勇人");
  }

  Future login({required String loginId, required String password}) async {
    // WebAPIのレスポンスを待ってる風
    await Future.delayed(const Duration(microseconds: 500));

    if (loginId == "sakamoto@example.com" && password == "%!vTdkQ#wJ7|") {
      return const User(loginId: "sakamoto@example.com", name: "坂本 勇人");
    } else {
      throw Exception("ログインに失敗しました。");
    }
  }
}

async_login_controller.dart

part 'async_blog.g.dart';

@riverpod
AsyncAuthRepository asyncAuthRepository(AsyncAuthRepositoryRef ref) {
  return AsyncAuthRepository();
}

@riverpod
class AsyncLoginController extends _$AsyncLoginController {
  @override
  FutureOr<User?> build() async {
    return await ref.read(asyncAuthRepositoryProvider).verifyLoginUser();
  }

  Future login({required String loginId, required String password}) async {
    state = const AsyncValue.loading();

    // AsyncValue.guardでエラーハンドリング
    state = await AsyncValue.guard(() async {
      if (state.value == null) {
        // LoginId、Passwordの入力チェック
        if (loginId.isNotEmpty && password.isNotEmpty) {
          return await ref.read(asyncAuthRepositoryProvider).login(loginId: loginId, password: password);
        } else {
          throw Exception("ログインID、パスワードは必須です");
        }
      } else {
        // ログイン済みでログインを実施する操作ができるのはプログラムの不具合なのでErrorをthrow
        throw StateError("ログイン済の状態でログイン処理を実行しようとしました");
      }
    });
  }
}

上記では、AsyncLoginControllerbuildメソッドでログイン状態の確認を実施しています。
ログイン状態の確認はAsyncAuthRepository.verifyLoginUserで非同期に行われるため、戻り値はFutureまたはFutureOrになります。

この状態でBuild Runnerを走らせるとAsyncLoginControllerAutoDisposeAsyncNotifierを継承します。
AutoDisposeAsyncNotifierは遡るとAsyncNotifierBaseを継承しており、ステートはAsyncValueでラップされ、loading、errorなどの状態を管理することになります。

このケースでのAsyncValueの初期値はloadingになっており、処理が完了した場合やエラー発生時に状態が変化します。
そのため、今回のケースを評価しようとした場合、controller.stateのみを評価すると以下のようにloadingが正常に動作しているか評価ができない状態になります。

// ログイン済みの場合のスタブを生成
when(
  authRepository.verifyLoginUser(),
).thenAnswer((_) {
  return Future.value(const User(
    loginId: "tanaka@example.com",
    name: "田中 太郎",
  ));
});

final container = createContainer();
// Controllerの初期化まで待機
await container.read(asyncLoginControllerProvider.future);

// Controllerを取得
final controller = container.read(asyncLoginControllerProvider.notifier);

// 初期化の結果は評価できるけど、途中経過が評価できない
expect(
  controller.state,
  isA<AsyncData<User?>>()
      .having((p0) => p0.value!.loginId, "loginId", "tanaka@example.com")
      .having((p0) => p0.value!.name, "name", "田中 太郎"),
);

このようなケースではlistenerのpreviousnextを評価したり、verifyInOrderで順序を評価することで、状態の遷移を評価することができます。
変更されたloginメソッドへの評価を含めたものを以下に記載します。

async_login_controller_test.dart

abstract interface class ValueChangeListener {
  void call<T>(T previous, T next);
}

@GenerateNiceMocks([MockSpec<AsyncAuthRepository>(), MockSpec<ValueChangeListener>()])
void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  final authRepository = MockAsyncAuthRepository();
  final listener = MockValueChangeListener();

  setUp(() {
    reset(authRepository);
    reset(listener);
  });

  ProviderContainer createContainer() =>
      ProviderContainer(
          overrides: [
            asyncAuthRepositoryProvider.overrideWithValue(authRepository),
          ]
      );

  group("init", () {
    test("logged in", () async {
      // ログイン済みの場合のスタブを生成
      when(
        authRepository.verifyLoginUser(),
      ).thenAnswer((_) {
        return Future.value(const User(
          loginId: "tanaka@example.com",
          name: "田中 太郎",
        ));
      });

      final container = createContainer();
      // StateをValueChangeListenerで監視
      container.listen(asyncLoginControllerProvider, listener.call);

      // Controllerの初期化まで待機
      await container.read(asyncLoginControllerProvider.future);

      // loading -> 田中 太郎
      verify(
        listener.call(
          isA<AsyncLoading<User?>>(),
          isA<AsyncData<User?>>()
              .having((p0) => p0.value!.loginId, "loginId", "tanaka@example.com")
              .having((p0) => p0.value!.name, "name", "田中 太郎"),
        ),
      );
      verify(
        authRepository.verifyLoginUser(),
      ).called(1);
    });

    ...
  });

  group("login", () {
    test("success", () async {
      // 未ログインの場合のスタブを生成
      when(
        authRepository.verifyLoginUser(),
      ).thenAnswer((_) => Future.value(null));
      // ログイン処理のスタブを生成
      when(
        authRepository.login(
          loginId: argThat(equals("tanaka@example.com"), named: "loginId"),
          password: argThat(equals("8peJuzJ*naBd"), named: "password"),
        ),
      ).thenAnswer((_) async => const User(
        loginId: "tanaka@example.com",
        name: "田中 太郎",
      ));

      final container = createContainer();
      // StateをValueChangeListenerで監視
      container.listen(asyncLoginControllerProvider, listener.call);

      // Controllerの初期化完了まで待機
      await container.read(asyncLoginControllerProvider.future);

      // 初期化処理分の呼び出しをリセット
      clearInteractions(authRepository);
      clearInteractions(listener);

      final controller = container.read(asyncLoginControllerProvider.notifier);
      await controller.login(loginId: "tanaka@example.com", password: "8peJuzJ*naBd");

      verifyInOrder([
        // null -> loading
        listener.call(
          isA<AsyncData<User?>>()
              .having((p0) => p0.value, "user", isNull),
          isA<AsyncLoading<User?>>(),
        ),
        // loading -> 田中 太郎
        listener.call(
          isA<AsyncLoading<User?>>(),
          isA<AsyncData<User?>>()
              .having((p0) => p0.value!.loginId, "loginId", "tanaka@example.com")
              .having((p0) => p0.value!.name, "name", "田中 太郎"),
        ),
      ]);
      verify(
        authRepository.login(
          loginId: "tanaka@example.com",
          password: "8peJuzJ*naBd",
        ),
      ).called(1);
    });

    ...
  });
}

まとめ

いかがでしたでしょうか。
RiverpodのNotifierを評価する場合、listenerを用いることで状態の遷移を評価できるため、より詳細なテストを実施できるようになりました。
初期値の存在しないNotifierであれば結果のみ評価するのでも十分なケースがありますが、手法が一貫している方が迷いがなくなると思うので、初期値の有無や非同期処理の有無に関わらずlistenerを用いた評価をしていこうと思いました。