🤖

riverpod/state_notifier を使ったFlutterアプリのUnit Test

2022/04/23に公開

riverpodを使ったFlutterアプリのサンプルをgithubに公開しており、それについての記事を続きでテストも書いてみました。
まだ書いてみたばかりなのでベストプラクティスは模索中ではあります。

このFlutterアプリのアーキテクチャは下記の通りで、
https://note.com/hikarusato/n/n59ad456faaf1
https://zenn.dev/hs7/articles/617549d7a3e8f8

この記事では、上記FlutterアプリのController層についてのテスト(Controller層の処理実行後のstateを検証)を書いていきます。👇のような感じです。

ちなみに、riverpodのテストのドキュメントは下記の通りです。結構あっさりしてます。
https://riverpod.dev/ja/docs/cookbooks/testing/

テストコード

この記事で説明するテストコードは👇です。

https://github.com/HikaruSato/flutter-architecture-example/pull/2/files

import 'package:example_app/core/diaries/controllers/diaries_controller.dart';
import 'package:example_app/core/diaries/models/diary.dart';
import 'package:example_app/core/diaries/repositories/diary_repository.dart';
import 'package:example_app/core/diaries/repositories/diary_repository_impl.dart';
import 'package:example_app/shared/models/app_exception.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class FakeDiaryRepository implements DiaryRepository {
  FakeDiaryRepository({required this.mockGetDiaries});

  final Future<List<Diary>> Function() mockGetDiaries;

  
  Future<List<Diary>> getDiaries({startIndex = 0, count = 30}) {
    return mockGetDiaries();
  }
}

void main() {
  group('DiariesController#getDiaries', () {
    late Future<List<Diary>> mockGetDiaries;
    late ProviderContainer container;

    setUp(() {
      container = ProviderContainer(
        overrides: [
          diaryRepositoryProvider.overrideWithProvider(
            Provider((ref) => FakeDiaryRepository(mockGetDiaries: () => mockGetDiaries)),
          ),
        ],
      );
    });

    test('リストがstateに設定されていること', () async {
      final controller = container
          .listen(
            diariesControllerProvider.notifier.select((value) => value),
            (previous, next) {},
          )
          .read();
      final createdAt = DateTime.now();

      // 初回読み込み
      mockGetDiaries = Future(() => [Diary('1', createdAt)]);
      await controller.getDiaries(isInit: true);
      DiariesPageState state = controller.debugState;
      expect(state.diaries.length, 1);
      expect(state.diaries[0], Diary('1', createdAt));

      // 追加読み込み
      mockGetDiaries = Future(() => [Diary('2', createdAt)]);
      await controller.getDiaries();
      state = controller.debugState;
      expect(state.diaries.length, 2);
      expect(state.diaries[0], Diary('1', createdAt));
      expect(state.diaries[1], Diary('2', createdAt));
    });

    test('リスト取得でエラーの場合にExceptionがstateに設定されていること', () async {
      final controller = container
          .listen(
            diariesControllerProvider.notifier.select((value) => value),
            (previous, next) {},
          )
          .read();

      mockGetDiaries = Future.error(Exception('error'));
      await controller.getDiaries();
      final state = controller.debugState;
      expect(state.exception, isException);
      expect((state.exception as AppException).message, '一覧の取得に失敗しました');
    });
  });
}

詳細に説明していきます。

Repositoryの抽象化

このFlutterアプリプロジェクトではもともと下記のようにRepositoryの抽象化はしていました。

abstract class DiaryRepository {
  Future<List<Diary>> getDiaries({startIndex = 0, count = 30});
}

ですが、アプリ側のコードではProviderが提供するDiaryRepositoryDiaryRepositoryImplDiaryRepositoryを実装したクラス)となっていたので下記のような修正をしています。

// 変更前: 型推論で、Provider<DiaryRepositoryImpl>になっていた。
// final diaryRepositoryProvider = Provider((ref) => DiaryRepositoryImpl());

// 変更後: Provider<DiaryRepository> にして、抽象クラスのDiaryRepositoryを外側に公開するようにした。
final Provider<DiaryRepository> diaryRepositoryProvider = Provider((ref) => DiaryRepositoryImpl());

Dartのクラスは Implicit interfacesがあるので、こういう感じで抽象クラスにしなくてもDiaryRepositoryImplクラスからMock用クラスを定義する、という手法でも全然良さそうではあります(むしろ、それがDartでは一般的にも感じてます)。Implicit interfacesについては別記事を書きました。
https://zenn.dev/hs7/articles/ce4fe192d7c0e4

Mock用のRepository定義

今回はController層をテストするため、下位レイヤーにあたるRepository層をMock化します。
先ほど抽象化した DiaryRepository を実装して下記のようにFakeDiaryRepositoryクラスを定義します。

class FakeDiaryRepository implements DiaryRepository {
  FakeDiaryRepository({required this.mockGetDiaries});

  final Future<List<Diary>> Function() mockGetDiaries;

  
  Future<List<Diary>> getDiaries({startIndex = 0, count = 30}) {
    return mockGetDiaries();
  }
}

Future<List<Diary>> getDiaries()メソッドの戻り値をテストケースによって変えるため、
コンストラクタの引数に Future<List<Diary>> Function() mockGetDiaries を指定できるようにしています。
Function型とすることでFuture<List<Diary>> getDiaries()メソッドを実行する毎に戻り値を変えれるようにしています。

setUp

テストのセットアップです。
setUp() は各テストケース毎(各test()が実行される毎)に実行されます。
テストケース毎に Future<List<Diary>> mockGetDiaries (FakeDiaryRepository#getDiaries()メソッドの戻り値)を設定するためにsetUp() の外側に定義しています。

void main() {
  group('DiariesController#getDiaries', () {
    late Future<List<Diary>> mockGetDiaries;
    late ProviderContainer container;

    setUp(() {
      container = ProviderContainer(
        overrides: [
          diaryRepositoryProvider.overrideWithProvider(
            Provider((ref) => FakeDiaryRepository(mockGetDiaries: () => mockGetDiaries)),
          ),
        ],
      );
    });

正常系test

    test('リストがstateに設定されていること', () async {
      final controller = container
          .listen(
            diariesControllerProvider.notifier.select((value) => value),
            (previous, next) {},
          )
          .read();
      final createdAt = DateTime.now();

      // 初回読み込み
      mockGetDiaries = Future(() => [Diary('1', createdAt)]);
      await controller.getDiaries(isInit: true);
      DiariesPageState state = controller.debugState;
      expect(state.diaries.length, 1);
      expect(state.diaries[0], Diary('1', createdAt));

      // 追加読み込み
      mockGetDiaries = Future(() => [Diary('2', createdAt)]);
      await controller.getDiaries();
      state = controller.debugState;
      expect(state.diaries.length, 2);
      expect(state.diaries[0], Diary('1', createdAt));
      expect(state.diaries[1], Diary('2', createdAt));
    });

controllerの取得

      final controller = container
          .listen(
            diariesControllerProvider.notifier.select((value) => value),
            (previous, next) {},
          )
          .read();

まず、最初のところですが、テスト対象のDiariesControllerを取得しています。
DiariesControllerProviderは、 AutoDisposeStateNotifierProvider<DiariesController, DiariesPageState> diariesControllerProviderなのですが、AutoDisposeかつStateNotifierProviderの場合は、下記のような感じでユニットテストではcontrollerを取得できるようです。
この辺はググってもあまり情報が見つけれらなかったので、デファクトスタンダートな方法がわかれば更新していこうと思います...🙏

メソッドのmock

     final createdAt = DateTime.now();

      // 初回読み込み
      mockGetDiaries = Future(() => [Diary('1', createdAt)]);
      await controller.getDiaries(isInit: true);

最初のmockGetDiaries = Future(() => [Diary('1', createdAt)]);controller.getDiaries()した時の戻り値を決定しています。

初回読み込み時のstate検証

      DiariesPageState state = controller.debugState;
      expect(state.diaries.length, 1);
      expect(state.diaries[0], Diary('1', createdAt));

controller.debugStateを使って、先ほどのメソッド実行で設定されたstateを取得しています。debugStateは下記のような説明の通りユニットテストで利用するっぽいです。

https://pub.dev/documentation/state_notifier/latest/state_notifier/StateNotifier/debugState.html

Containing class: StateNotifier  
A development-only way to access state outside of StateNotifier.
The only difference with state is that debugState is not "protected".\ Will not work in release mode.
This is useful for tests.

追加読み込み時のstate検証

初回読み込みしたstateを維持したまま追加読み込みされていたかのテストを書いてます。

      // 追加読み込み
      mockGetDiaries = Future(() => [Diary('2', createdAt)]);
      await controller.getDiaries();
      state = controller.debugState;
      expect(state.diaries.length, 2);
      expect(state.diaries[0], Diary('1', createdAt));
      expect(state.diaries[1], Diary('2', createdAt));

追加読み込みのテスト用に mockGetDiariesを指定して、await controller.getDiaries();を実行して、 controller.debugStateを取得し直して、state内容を検証しています。

エラー系test

    test('リスト取得でエラーの場合にExceptionがstateに設定されていること', () async {
      final controller = container
          .listen(
            diariesControllerProvider.notifier.select((value) => value),
            (previous, next) {},
          )
          .read();

      mockGetDiaries = Future.error(Exception('error'));
      await controller.getDiaries();
      final state = controller.debugState;
      expect(state.exception, isException);
      expect((state.exception as AppException).message, '一覧の取得に失敗しました');
    });

mockGetDiaries = Future.error(Exception('error'));controller.getDiaries() 実行後にエラーがstateに設定されるようにしています。正常系のテストと同じようにcontroller.debugStateを取得してstateにエラーメッセージが設定されたかを検証しています。

Discussion