【Flutter】AsyncNotifierをテストする

**2023/10/17 修正** 

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

https://riverpod.dev/ja/docs/essentials/testing


-------



# テストを書こう！
Flutterの状態管理としてデファクトになりつつある[riverpod](https://pub.dev/packages/riverpod
)ですが、バージョン2.0.0で導入された`AsyncNotifier`で非同期処理が楽になりました。今回はテストを書いていきましょう！


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

https://codewithandrea.com/articles/flutter-riverpod-async-notifier/

https://codewithandrea.com/articles/unit-test-async-notifier-riverpod/

## 使用ライブラリ

- riverpod 2.1.3
- riverpod_annotation 1.1.1
- riverpod_generator 1.1.1
- mockito 5.2.0

## ソースコード

この記事で利用するコードはすべて[Githubリポジトリで公開しています](https://github.com/Seo-4d696b75/dart-async-test-sample/tree/riverpod2)

# テスト対象

TextFieldにキーワードを入力すると検索結果を一覧表示します
![ezgif-1-4f8e1e048c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/e6a6c293-0845-3d28-0164-dd1903266347.gif)


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

```dart: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`の詳細はこちらの記事が詳しいです

https://zenn.dev/tsuruo/articles/52f62fc78df6d5

riverpod_generatorを使う場合は、

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

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


```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);
  }
}

```

:::note info
Providerのリロード

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

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

:::


# テストコード

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

## コードの全体像
```dart
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 することです。

:::note info
`AsyncNotifierProvider.future`

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


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


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

```dart
      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で捕捉＆確認します。


:::note warn
エラーハンドリング

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

- providerの`state`は`AsyncError`にセットされる
- `AsyncNotifierProvider.future`はthrowされた例外で失敗する
:::


```dart
      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`しています。

```dart
      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"])),
          ),
          // 検索中（２回目）
          listener.call(
            argThat(isA<AsyncData>()),
            argThat(isA<AsyncLoading>()
                .having((d) => d.isReloading, "isReloading", isTrue)
                .having((d) => d.value, "value", ["result1"])),
          ),
          // 検索完了（２回目）
          listener.call(
            argThat(isA<AsyncLoading>()),
            argThat(isA<AsyncData>()
                .having((d) => d.value, "value", ["result2"])),
          ),
        ]);
      });
```

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

## 再読み込みのテスト

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

:::note info
Providerのリフレッシュ

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

- `build`関数が再度実行される
- リフレッシュ中は`isLoading == true`かつ`isRefreshing == true`
- リロードとは異なり、`isReloading == false`

:::

```dart
      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"]`を保持し続けます。