18
3

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.

株式会社ゆめみAdvent Calendar 2022

Day 16

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

Last updated at Posted at 2022-12-15

2023/01/27 修正

  • Streamの監視方法を修正しました
  • ProviderContainerを利用する方法を追加しました

2023/02/03 追記
riverpod 2.0.0 で導入されたAsyncNotifierを利用すると非同期処理がより容易に書けます
AsyncNotifierのテストはこちらの記事を参照してください

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

テストを書こう!

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

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

この記事で解説すること

  • 非同期処理をユニットテスト
  • Mockitoでasyncな関数をモックしてテスト
  • StateNotifierのstateをテスト

解説しないこと・前提知識

  • freezedの使い方
  • riverpodによる状態管理・providerの使い方
  • 基本的なテストの書き方
  • Mockitoによる依存のモック方法

使用ライブラリ

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リポジトリで公開しています

テスト対象

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

アプリの動作イメージ

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

状態クラス

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

  • Empty: 検索結果なし・検索中でもない
  • Loading: 検索中
  • Data: 検索結果あり
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を呼び出すことだけ知っていれば十分です。(簡単のためエラーハンドリングなどの処理を省略しています)

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でモックします。

asyncな関数のモック

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

Providerの状態を検査する

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

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

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

失敗〜怪しい書き方

失敗1

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

    test("失敗1", () 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'

失敗2

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

diff with 失敗1
    test("失敗2", () 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 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から参照する要領で使えます。

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

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

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

listenerのモック

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

+ 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をラップして渡す
    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

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

    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の監視を開始します。

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

(非推奨)非同期処理を待機する

非推奨

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

Completerを用いた実装

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

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

    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"]);
    });
18
3
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
18
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?