8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Flutter】AsyncNotifierをテストする

Last updated at Posted at 2023-02-03

2023/10/17 修正

テスに関する公式ドキュメントを反映しました。


テストを書こう!

Flutterの状態管理としてデファクトになりつつあるriverpodですが、バージョン2.0.0で導入されたAsyncNotifierで非同期処理が楽になりました。今回はテストを書いていきましょう!

AsyncNotifierの導入・入門に関してはこちらの記事が参考になりました。

使用ライブラリ

  • riverpod 2.1.3
  • riverpod_annotation 1.1.1
  • riverpod_generator 1.1.1
  • mockito 5.2.0

ソースコード

この記事で利用するコードはすべてGithubリポジトリで公開しています

テスト対象

TextFieldにキーワードを入力すると検索結果を一覧表示します
ezgif-1-4f8e1e048c.gif

検索機能

検索機能はテスト時にモックと差し替えやすいようUseCaseとして用意します。詳細な実装は今回のスコープから外れるので説明しませんが、とりあえずUseCaseを呼び出すことだけ知っていればOK

search_usecase.dart
final searchUseCaseProvider = Provider(
  (ref) => SearchUseCase(),
);

class SearchUseCase {
  SearchUseCase();

  Future<List<String>> call(String keyword) async {
    // 何らかの実装
    throw UnimplementedError();
  }
}

Providerの定義

肝心のAsyncNotifierはここで登場します。S型の状態を持つAsyncNotifier<S>では、読み込み中やエラーの状態も考慮したAsyncValue<S>型として外部から状態を参照できます。

AsyncValueの詳細はこちらの記事が詳しいです

riverpod_generatorを使う場合は、

  • 対象クラスを@riverpodで指定
  • 関数buildの返り値の型にFutureOr<S>を指定

あとは build_runnerを走らせるだけで必要なコードを自動生成してくれます。コードにAsyncNotifierのクラス名は直接は出てきませんが、自動生成された*.g.dartファイルの中で見つけられます。

search_result.dart
part 'search_result.g.dart';

// 検索キーワード
final searchWordProvider = StateProvider((ref) => 'keyword');

@riverpod
class SearchResult extends _$SearchResult {
  SearchUseCase get searchWord => ref.read(searchUseCaseProvider);

  @override
  FutureOr<List<String>> build() async {
    // キーワードが更新される度に実行する
    final word = ref.watch(searchWordProvider);
    return await searchWord(word);
  }
}

Providerのリロード

build関数内でwatchしている依存が更新されるとAsyncNotifierのproviderがリロードされます

  • build関数が再度実行される
  • リロード中はAsyncLoadingかつisReloading == true
  • 後述のrefresh/invalidateとは異なり、isRefreshing == false

テストコード

ではテストを書いていきましょう

コードの全体像

abstract class ChangeListener<T> {
  void call(T? previous, T next);
}

@GenerateMocks([SearchUseCase, ChangeListener])
void main() {
  final mockUseCase = MockSearchUseCase();
  final listener = MockChangeListener<AsyncValue<List<String>>>();

  ProviderContainer init() {
    final container = ProviderContainer(
      overrides: [
        searchUseCaseProvider.overrideWithValue(mockUseCase),
      ],
    );
    addTearDown(container.dispose);
    container.listen(
      searchViewModelProvider,
      listener,
      // 状態の変化が即座・同期的にコールバックされるのでテストが容易になる
      fireImmediately: true,
    );
    return container;
  }

  group("SearchResult", () {
    setUp(() {
      reset(listener);
      reset(mockUseCase);
    });
    // 各テストケース
    test("sample", () {
      final container = init();

      // 1. 最初の AsyncData/Error を待機&検査する
      await expectLater(
        container.read(searchResultProvider.future),
        completion(yourMatcher),
      );
  
      // 2. 登録したリスナーの呼び出しから状態変化を検査する
      verify(listener.call(any, argThat(yourMatcher)));
    });
  });

準備

init関数でProviderContainerを用意します。UseCaseはモックを参照するようにoverrideしていますが、他にも依存があれば適宜モックに差し替えましょう。各テストケース終了後にはdisponseを呼び出す後処理も忘れずに。

状態の検査

StateNotifierとは異なりAsyncNotifierにはdebugState, streamのプロパティが存在しません。代わりにProviderContainerから対象のproviderをread/listenします。

一番簡単な方法は 1. のAsyncNotifierProvider.futureを await することです。

AsyncNotifierProvider.future

read関数に渡すとFuture型が取得でき、AsyncLoading以外で一番最初にstateにセットされた値で完了します。

ただしbuild関数を実行中の状態は観測できないので、本記事では主に 2. のProviderContainer.listenを利用した方法を紹介します。モックしたリスナーを登録しておきverifyで呼び出しを確認すれば、状態の変化を監視できるわけです。

初期化のテスト

build関数で行われる初期化を確認します。まずは成功の場合から見てみましょう。ポイントはAsyncNotifierProvider.futureを利用し初期化の完了を待機する点です。

      test("初期化処理の成功", () async {
        when(mockUseCase.call(any)).thenAnswer(
          (_) async => ["result"],
        );
        final container = init();
        await container.read(searchResultProvider.future);

        verifyInOrder([
          // 検索中
          listener.call(argThat(isNull), argThat(isA<AsyncLoading>())),
          // 検索完了
          listener.call(
            argThat(isA<AsyncLoading>()),
            argThat(isA<AsyncData>()
                .having((d) => d.value, "value", ["result"])),
          ),
        ]);
        verify(mockUseCase.call("keyword")).called(1);
      });

次に失敗の場合を見てみます。AsyncNotifierProvider.futureで初期化の完了を待機するのは同じですが、例外の捕捉を忘れずに。ここではthrowsExceptionのmatcherで捕捉&確認します。

エラーハンドリング

build関数内で例外がthrowされると

  • providerのstateAsyncErrorにセットされる
  • AsyncNotifierProvider.futureはthrowされた例外で失敗する
      test("初期化処理の失敗", () async {
        when(mockUseCase.call(any)).thenAnswer(
          (_) async => throw Exception("test"),
        );
        final container = init();
        await expectLater(
          container.read(searchResultProvider.future),
          throwsException,
        );

        verifyInOrder([
          // 検索中
          listener.call(argThat(isNull), argThat(isA<AsyncLoading>())),
          // 検索失敗
          listener.call(
            argThat(isA<AsyncLoading>()),
            argThat(isA<AsyncError>()),
          ),
        ]);
        verify(mockUseCase.call("keyword")).called(1);
      });

検索キーワード更新のテスト

次はsearchWordProviderが保持するキーワードが変化したとき、検索処理が再実行されるテストです。初期化の部分は重複しますが全部の状態変化をverifyしています。

      test("キーワード更新 > 検索処理の成功", () async {
        when(mockUseCase.call(any)).thenAnswer(
          (_) async => ["result1"],
        );
        final container = init();
        await container.read(searchResultProvider.future);

        when(mockUseCase.call(any)).thenAnswer(
          (_) async => ["result2"],
        );
        container.read(searchWordProvider.notifier).state = "keyword2";
        await container.read(searchResultProvider.future);

        verifyInOrder([
          // 検索中(初期化)
          listener.call(argThat(isNull), argThat(isA<AsyncLoading>())),
          // 検索完了(初期化成功)
          listener.call(
            argThat(isA<AsyncLoading>()),
            argThat(isA<AsyncData>()
                .having((d) => d.value, "value", ["result1"])),
          ),
          // 検索中(2回目)
          listener.call(
            argThat(isA<AsyncData>()),
            argThat(isA<AsyncLoading>()
                .having((d) => d.isReloading, "isReloading", isTrue)
                .having((d) => d.value, "value", ["result1"])),
          ),
          // 検索完了(2回目)
          listener.call(
            argThat(isA<AsyncLoading>()),
            argThat(isA<AsyncData>()
                .having((d) => d.value, "value", ["result2"])),
          ),
        ]);
      });

注目すべき点として、2回目の検索中はAsyncLoading状態でありながらhasValue == trueであり、直前の状態["result1"]を保持し続けます。

再読み込みのテスト

検索キーワードは変わらず、refresh/invalidate関数によってproviderをリフレッシュする場合を考えます。テストの場合はリフレッシュ完了を待機する必要があるので、refresh関数の方を使います

Providerのリフレッシュ

refresh/invalidate関数を利用するとproviderを最初から生成します。依存の更新時にreloadされる場合とは異なり、ユーザー操作など外的な理由でproviderを更新する用途に適切です。

  • build関数が再度実行される
  • リフレッシュ中はisLoading == trueかつisRefreshing == true
  • リロードとは異なり、isReloading == false
      test("refresh > 検索処理の成功", () async {
        when(mockUseCase.call(any)).thenAnswer(
          (_) async => ["result1"],
        );
        final container = init();
        await container.read(searchResultProvider.future);

        when(mockUseCase.call(any)).thenAnswer(
          (_) async => ["result2"],
        );

        // provider の.future を渡すと
        // 再実行されるbuild関数の結果で完了するようなFutureが得られる
        await container.refresh(searchResultProvider.future);

        verifyInOrder([
          // 検索中(初期化)
          listener.call(argThat(isNull), argThat(isA<AsyncLoading>())),
          // 検索完了(初期化成功)
          listener.call(
            argThat(isA<AsyncLoading>()),
            argThat(isA<AsyncData>()
                .having((d) => d.isLoading, "isLoading", isFalse)
                .having((d) => d.value, "value", ["result1"])),
          ),
          // リフレッシュ中
          listener.call(
            argThat(isA<AsyncData>()),
            argThat(isA<AsyncData>()
                .having((d) => d.isLoading, "isLoading", isTrue)
                .having((d) => d.isRefreshing, "isRefreshing", isTrue)
                .having((d) => d.value, "value", ["result1"])),
          ),
          // リフレッシュ完了
          listener.call(
            argThat(isA<AsyncData>()),
            argThat(isA<AsyncData>()
                .having((d) => d.isLoading, "isLoading", isFalse)
                .having((d) => d.value, "value", ["result2"])),
          ),
        ]);
      });

providerのリロードと同様に、リフレッシュ中は直前の状態["result1"]を保持し続けます。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?