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による依存のモック方法
使用ライブラリ
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
: 検索結果あり
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を呼び出すことだけ知っていれば十分です。(簡単のためエラーハンドリングなどの処理を省略しています)
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()
で読み替えてください。
@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状態が確認できません。非同期な検索処理の完了を待っていないからです。
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状態の確認を遅らせ検索処理の完了を待ちます。
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"]);
});