【Flutter】非同期処理のテスト

**2023/01/27 修正**
- Streamの監視方法を修正しました
- ProviderContainerを利用する方法を追加しました

**2023/02/03 追記**
riverpod 2.0.0 で導入されたAsyncNotifierを利用すると非同期処理がより容易に書けます
[AsyncNotifierのテストはこちらの記事を参照してください](https://qiita.com/Seo-4d696b75/items/eee020162d0537fdbc36)

**2023/10/16 修正**
テストに関する公式ドキュメントを反映しました。**本記事より先に目を通すよう強くお勧めします。**

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


# テストを書こう！

より良いコードを書くためにテストの重要性は言うまでもありません！しかし非同期処理が関わると煩雑でテストの追加を躊躇ってしまうことも...そんな時の助けになればと備忘録を兼ねて書き残します。

ここでは、Flutterを想定してriverpodによる状態管理をサンプルに説明します。

### この記事で解説すること
- 非同期処理をユニットテスト
- Mockitoでasyncな関数をモックしてテスト
- StateNotifierのstateをテスト

### 解説しないこと・前提知識
- freezedの使い方
- riverpodによる状態管理・providerの使い方
- 基本的なテストの書き方
- Mockitoによる依存のモック方法

### 使用ライブラリ
```yaml:pubspec.yaml
dependencies:
  freezed_annotation: ^1.1.0
  hooks_riverpod: ^1.0.3

dev_dependencies:
  build_runner: ^2.1.7
  freezed: ^1.1.0
  mockito: ^5.2.0
```

### コード全文

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

# テスト対象
あるキーワードを指定すると検索を実行して、結果の文字列を複数返すような機能を想定します。

アプリの動作イメージ
<img src="https://user-images.githubusercontent.com/25225028/207562903-57e75115-3989-45c0-8401-404cdf444063.gif" width="300">

アプリの完全なソースコードはGithubを参照してください

https://github.com/Seo-4d696b75/dart-async-test-sample

## 状態クラス

https://pub.dev/packages/freezed#union-types-and-sealed-classes

freezedでimmutableに固めて、UnionTypeの要領で各状態を表現します（簡単のため最小限の状態だけ定義しています）。

- `Empty`: 検索結果なし・検索中でもない
- `Loading`: 検索中
- `Data`: 検索結果あり

```dart:search_state.dart
part 'search_state.freezed.dart';

@freezed
class SearchState with _$SearchState {
  const factory SearchState.empty() = SearchStateEmpty;

  const factory SearchState.loading({
    required String keyword,
  }) = SearchStateLoading;

  const factory SearchState.data({
    required List<String> hits,
  }) = SearchStateData;
}
```
## ViewModel
今回はMVVMライクに設計します。`StateNotifier`で作成したViewModelが先ほどの状態を保持し、検索を実行する関数`search()`も生やしています。検索の具体的な実装は今回のテスト対象外とし、とりあえずUseCaseを呼び出すことだけ知っていれば十分です。（簡単のためエラーハンドリングなどの処理を省略しています）

```dart:search_view_model.dart
final searchViewModelProvider =
    StateNotifierProvider<SearchViewModel, SearchState>(
  (ref) => SearchViewModel(
    ref.watch(searchUseCaseProvider),
  ),
);

class SearchViewModel extends StateNotifier<SearchState> {
  SearchViewModel(this.searchWord) : super(const SearchState.empty());

  final SearchUseCase searchWord;

  Future<void> search(String keyword) async {
    // 検索中の状態
    state = SearchState.loading(keyword: keyword);
    // 検索の実行
    final result = await searchWord(keyword);
    // 検索結果の反映
    state = SearchState.data(hits: result);
  }
}


final searchUseCaseProvider = Provider(
  (_) => SearchUseCase(),
);

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

# テストコード
ユニットテストで検索中にloading状態＆検索終了後にdata状態であることを確認します。検索の実装はテスト対象ではないので、UseCaseはMockitoでモックします。

:::note warn
asyncな関数のモック

Futureを`thenReturn`で返そうとするとErrorになるので、代わりに`thenAnswer`にasyncな関数を渡します。
:::

https://pub.dev/packages/mockito#a-quick-word-on-async-stubbing

:::note info
Providerの状態を検査する

`StateNotifier.state`は`@protected`なのでテストで参照する場合は`debugState`を利用します。riverpod 2.0.0 以降の場合は、削除された`debugState`の代わりに`ProviderContainer#read()`で読み替えてください。
:::


```dart:search_view_model_test.dart
@GenerateMocks([SearchUseCase])
void main() {
  final mockUseCase = MockSearchUseCase();

  group("検索中のloading状態を確認", () {
    test("テストケース", () {
      // テスト対象
      final viewModel = SearchViewModel(mockUseCase);
      // 最初はEmpty状態
      expect(viewModel.debugState, isA<SearchStateEmpty>());
      // UseCaseをモック
      when(mockUseCase.call(any)).thenAnswer((_) async {
        return ["result1", "result2"];
      });
      // test code here
    });
  });
}
```

次にダメなテストを紹介し、最後にきちんと非同期処理を扱えるテストを何通りか見ていきます

## 失敗〜怪しい書き方

### 失敗１

非同期に`SearchUseCase.call()`を呼び出している間だけloading状態になるので、`await viewModel.search()`で呼び出した後はすでに検索処理が終わっており、loading状態を観測できません。

```dart
    test("失敗１", () async {
      final viewModel = SearchViewModel(mockUseCase);
      when(mockUseCase.call(any)).thenAnswer((_) async {
        return ["result1", "result2"];
      });

      // 検索の開始
      await viewModel.search("keyword");
      // 検索中の状態を確認したい
      expect(viewModel.debugState, isA<SearchStateLoading>()); // Error

      // モック呼び出しを確認したい
      verify(mockUseCase.call("keyword")).called(1);
      // 検索終了後の状態を確認したい
      expect(viewModel.debugState, isA<SearchStateData>());
    });
```

```
Expected: <Instance of 'SearchStateLoading'>
  Actual: _$SearchStateData:<SearchState.data(keyword: keyword, hits: [result1, result2])>
   Which: is not an instance of 'SearchStateLoading'
```

### 失敗２

`await`を削除して、検索処理の完了を待たずにverifyしてみます。loading状態は確認できますが、今度は検索終了後のdata状態が確認できません。非同期な検索処理の完了を待っていないからです。


```diff:diff with 失敗1
    test("失敗２", () async {
      final viewModel = SearchViewModel(mockUseCase);
      when(mockUseCase.call(any)).thenAnswer((_) async {
        return ["result1", "result2"];
      });
      
-     await viewModel.search("keyword");
+     viewModel.search("keyword");
      expect(viewModel.debugState, isA<SearchStateLoading>()); // OK

      verify(mockUseCase.call("keyword")).called(1); // OK
      expect(viewModel.debugState, isA<SearchStateData>()); // Error
    });
```


```
Expected: <Instance of 'SearchStateData'>
  Actual: _$SearchStateLoading:<SearchState.loading(keyword: keyword)>
   Which: is not an instance of 'SearchStateData'
```


### 適当な遅延を挿入
テストが通るには、

- 非同期な検索処理の呼び出しを`await`で待機しない
- テストの任意の場所で検索処理の完了を待機する

と一見矛盾する条件を満たす必要があります。手っ取り早い方法は`Future.delay`でdata状態の確認を遅らせ検索処理の完了を待ちます。

```diff:diff with 失敗2
    test("怪しい非同期処理のテスト", () async {
      final viewModel = SearchViewModel(mockUseCase);
      when(mockUseCase.call(any)).thenAnswer((_) async {
        return ["result1", "result2"];
      });

      viewModel.search("keyword");
      expect(viewModel.debugState, isA<SearchStateLoading>()); // OK
 
+     await Future<void>.delayed(const Duration(milliseconds: 100));
      verify(mockUseCase.call("keyword")).called(1); // OK 
      expect(viewModel.debugState, isA<SearchStateData>()); // OK
    });
```

しかし何秒待機すればいいのでしょうか？待機時間が長いとテスト実行に時間がかかるので避けたいですが、短すぎると失敗しないのでしょうか？怪しいですね


## ProviderContainerをlistenする

UseCaseをテスト用のモックに差し替えるよう`override`を指定し、あとはいつも通り`WidgetRef`から参照する要領で使えます。

```dart
      final container = ProviderContainer(
        overrides: [
          searchUseCaseProvider.overrideWithValue(mockUseCase),
        ],
      );

      final viewModel = container.read(searchViewModelProvider.notifier);
```

テスト中の状態の監視は`ProviderContainer#listen`で行いますが、まずモックしたlistenerを用意します


:::note info
listenerのモック

Mockitoでは関数を直接モックできないので、`call`関数を持つクラスとしてモックします。通常のクラスのように`ChangeListener.call()`、もしくは `ChangeListener()`と関数のようにも呼び出せます。
:::

```diff
+ abstract class ChangeListener {
+   void call(SearchState? previous, SearchState next);
+ }
 
- @GenerateMocks([SearchUseCase])
+ @GenerateMocks([SearchUseCase, ChangeListener])
  void main() {
    final mockUseCase = MockSearchUseCase();
```

最終的なテストコードは以下のようになります。ポイントを何点か示すと、
- `addTearDown(container.dispose)`は後処理のお約束
- listenerを登録するとき、`fireImmediately: true`を指定すると変更が即座に（同期的に）伝達されるのでverifyが容易になる
- `verify*`でlistener呼び出しの引数を確認するには`argThat`でmatcherをラップして渡す

```dart
    test("Listen Container", () async {
      final container = ProviderContainer(
        overrides: [
          searchUseCaseProvider.overrideWithValue(mockUseCase),
        ],
      );
      addTearDown(container.dispose);

      when(mockUseCase.call(any)).thenAnswer((_) async {
        return ["result1", "result2"];
      });

      // listen
      final listener = MockChangeListener();
      container.listen(
        searchViewModelProvider,
        listener,
        fireImmediately: true,
      );

      // test
      final viewModel = container.read(searchViewModelProvider.notifier);
      await viewModel.search("keyword");

      // verify
      verifyInOrder([
        listener.call(argThat(isNull), argThat(isA<SearchStateEmpty>())),
        listener.call(
          argThat(isA<SearchStateEmpty>()),
          argThat(isA<SearchStateLoading>()
              .having((s) => s.keyword, "keyword", "keyword"),
        ),
        listener.call(
          argThat(isA<SearchStateLoading>()),
          argThat(isA<SearchStateData>()
              .having((s) => s.hits, "hits", ["result1", "result2"])),
        ),
      ]);
      verify(mockUseCase.call("keyword")).called(1);
    });
```


## （備考） Streamを監視する

`ProviderContainer`を利用せず、Providerが公開する`stream`で状態の変化を監視する方法です。loding, data状態が順に流れてくるのを確認しましょう。

[参考：How to Write Tests using Stream Matchers and Predicates in Flutter](https://codewithandrea.com/articles/async-tests-streams-flutter/)

streamから流れてくる値と順番を確認するには`emitsInOrder`というstream用のmatcherを使用します。

```dart
    test("streamを監視する", () async {
      final viewModel = SearchViewModel(mockUseCase);
      when(mockUseCase.call(any)).thenAnswer((_) async {
        return ["result1", "result2"];
      });

      // test
      await viewModel.search("keyword");

      // verify
      verify(mockUseCase.call("keyword")).called(1);
      expect(
        viewModel.stream,
        emitsInOrder([
          isA<SearchStateLoading>(),
          isA<SearchStateData>(),
        ]),
      );
    });
```

ただし、このままではテストは通りません。失敗もしませんが`expect`の呼び出しが永遠に終わらずTimeOutになります

`expect`呼び出した時点ですでにstreamからemitされた後だからです。そこで**検索処理を呼び出す前に**に`expectLater`を利用してstreamの監視を開始します。

```dart
    test("streamを監視する", () async {
      final viewModel = SearchViewModel(mockUseCase);
      when(mockUseCase.call(any)).thenAnswer((_) async {
        return ["result1", "result2"];
      });

      final verifyStream = expectLater(
        viewModel.stream,
        emitsInOrder([
          isA<SearchStateLoading>()
              .having((s) => s.keyword, "keyword", "keyword"),
          isA<SearchStateData>()
              .having((s) => s.hits, "hits", ["result1", "result2"]),
        ]),
      );

      // test
      await viewModel.search("keyword");

      // verify
      verify(mockUseCase.call("keyword")).called(1);
      await verifyStream;
    });
```

## （非推奨）~~非同期処理を待機する~~

:::note alert
非推奨

処理が完了するタイミングを制御しようとするとテストコードが複雑になり保守性が悪化します。公式ドキュメントに紹介があるように `ProviderContainer#listen`で状態の変化を検査してください。
:::

<details><summary> Completerを用いた実装</summary>

`Completer`を使うと、外部から任意のタイミングで完了するような`Future`を作成できます。これをラッチのように使えば非同期な検索処理の完了タイミングをよしなに制御できます。

ただし、モックした依存`mockUseCase`の呼び出しと`state`更新のタイミングに関してテスト側が実装を知っている必要があり、ブラックボックスなテストには向きません。

```dart
    test("非同期処理を正しく待機する", () async {
      final viewModel = SearchViewModel(mockUseCase);
      final searchCompleter = Completer<List<String>>();
      when(mockUseCase.call(any)).thenAnswer(
        // searchComplerter.complete()の呼び出しまでは完了しない
        (_) => searchCompleter.future,
      );

      // await で待機しない
      final searchCall = viewModel.search("keyword");
      expect(viewModel.debugState, isA<SearchStateLoading>());

      // 検索処理を完了させる
      searchCompleter.complete(["result1", "result2"]);
      await searchCall;

      // verify
      verify(mockUseCase.call("keyword")).called(1);
      final state = viewModel.debugState;
      expect(state, isA<SearchStateData>());
      state as SearchStateData;
      expect(state.hits, ["result1", "result2"]);
    });
```
</details>