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にキーワードを入力すると検索結果を一覧表示します
検索機能
検索機能はテスト時にモックと差し替えやすいようUseCaseとして用意します。詳細な実装は今回のスコープから外れるので説明しませんが、とりあえずUseCaseを呼び出すことだけ知っていればOK
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
ファイルの中で見つけられます。
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の
state
はAsyncError
にセットされる -
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"]
を保持し続けます。