riverpod/state_notifier を使ったFlutterアプリのUnit Test
riverpodを使ったFlutterアプリのサンプルをgithubに公開しており、それについての記事を続きでテストも書いてみました。
まだ書いてみたばかりなのでベストプラクティスは模索中ではあります。
このFlutterアプリのアーキテクチャは下記の通りで、
この記事では、上記FlutterアプリのController層についてのテスト(Controller層の処理実行後のstateを検証)を書いていきます。👇のような感じです。
ちなみに、riverpodのテストのドキュメントは下記の通りです。結構あっさりしてます。
テストコード
この記事で説明するテストコードは👇です。
import 'package:example_app/core/diaries/controllers/diaries_controller.dart';
import 'package:example_app/core/diaries/models/diary.dart';
import 'package:example_app/core/diaries/repositories/diary_repository.dart';
import 'package:example_app/core/diaries/repositories/diary_repository_impl.dart';
import 'package:example_app/shared/models/app_exception.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class FakeDiaryRepository implements DiaryRepository {
FakeDiaryRepository({required this.mockGetDiaries});
final Future<List<Diary>> Function() mockGetDiaries;
Future<List<Diary>> getDiaries({startIndex = 0, count = 30}) {
return mockGetDiaries();
}
}
void main() {
group('DiariesController#getDiaries', () {
late Future<List<Diary>> mockGetDiaries;
late ProviderContainer container;
setUp(() {
container = ProviderContainer(
overrides: [
diaryRepositoryProvider.overrideWithProvider(
Provider((ref) => FakeDiaryRepository(mockGetDiaries: () => mockGetDiaries)),
),
],
);
});
test('リストがstateに設定されていること', () async {
final controller = container
.listen(
diariesControllerProvider.notifier.select((value) => value),
(previous, next) {},
)
.read();
final createdAt = DateTime.now();
// 初回読み込み
mockGetDiaries = Future(() => [Diary('1', createdAt)]);
await controller.getDiaries(isInit: true);
DiariesPageState state = controller.debugState;
expect(state.diaries.length, 1);
expect(state.diaries[0], Diary('1', createdAt));
// 追加読み込み
mockGetDiaries = Future(() => [Diary('2', createdAt)]);
await controller.getDiaries();
state = controller.debugState;
expect(state.diaries.length, 2);
expect(state.diaries[0], Diary('1', createdAt));
expect(state.diaries[1], Diary('2', createdAt));
});
test('リスト取得でエラーの場合にExceptionがstateに設定されていること', () async {
final controller = container
.listen(
diariesControllerProvider.notifier.select((value) => value),
(previous, next) {},
)
.read();
mockGetDiaries = Future.error(Exception('error'));
await controller.getDiaries();
final state = controller.debugState;
expect(state.exception, isException);
expect((state.exception as AppException).message, '一覧の取得に失敗しました');
});
});
}
詳細に説明していきます。
Repositoryの抽象化
このFlutterアプリプロジェクトではもともと下記のようにRepository
の抽象化はしていました。
abstract class DiaryRepository {
Future<List<Diary>> getDiaries({startIndex = 0, count = 30});
}
ですが、アプリ側のコードではProvider
が提供するDiaryRepository
は DiaryRepositoryImpl
(DiaryRepository
を実装したクラス)となっていたので下記のような修正をしています。
// 変更前: 型推論で、Provider<DiaryRepositoryImpl>になっていた。
// final diaryRepositoryProvider = Provider((ref) => DiaryRepositoryImpl());
// 変更後: Provider<DiaryRepository> にして、抽象クラスのDiaryRepositoryを外側に公開するようにした。
final Provider<DiaryRepository> diaryRepositoryProvider = Provider((ref) => DiaryRepositoryImpl());
Dartのクラスは Implicit interfaces
があるので、こういう感じで抽象クラスにしなくてもDiaryRepositoryImpl
クラスからMock用クラスを定義する、という手法でも全然良さそうではあります(むしろ、それがDartでは一般的にも感じてます)。Implicit interfaces
については別記事を書きました。
Mock用のRepository定義
今回はController
層をテストするため、下位レイヤーにあたるRepository
層をMock化します。
先ほど抽象化した DiaryRepository
を実装して下記のようにFakeDiaryRepository
クラスを定義します。
class FakeDiaryRepository implements DiaryRepository {
FakeDiaryRepository({required this.mockGetDiaries});
final Future<List<Diary>> Function() mockGetDiaries;
Future<List<Diary>> getDiaries({startIndex = 0, count = 30}) {
return mockGetDiaries();
}
}
Future<List<Diary>> getDiaries()
メソッドの戻り値をテストケースによって変えるため、
コンストラクタの引数に Future<List<Diary>> Function() mockGetDiaries
を指定できるようにしています。
Function
型とすることでFuture<List<Diary>> getDiaries()
メソッドを実行する毎に戻り値を変えれるようにしています。
setUp
テストのセットアップです。
setUp()
は各テストケース毎(各test()が実行される毎)に実行されます。
テストケース毎に Future<List<Diary>> mockGetDiaries
(FakeDiaryRepository#getDiaries()
メソッドの戻り値)を設定するためにsetUp()
の外側に定義しています。
void main() {
group('DiariesController#getDiaries', () {
late Future<List<Diary>> mockGetDiaries;
late ProviderContainer container;
setUp(() {
container = ProviderContainer(
overrides: [
diaryRepositoryProvider.overrideWithProvider(
Provider((ref) => FakeDiaryRepository(mockGetDiaries: () => mockGetDiaries)),
),
],
);
});
正常系test
test('リストがstateに設定されていること', () async {
final controller = container
.listen(
diariesControllerProvider.notifier.select((value) => value),
(previous, next) {},
)
.read();
final createdAt = DateTime.now();
// 初回読み込み
mockGetDiaries = Future(() => [Diary('1', createdAt)]);
await controller.getDiaries(isInit: true);
DiariesPageState state = controller.debugState;
expect(state.diaries.length, 1);
expect(state.diaries[0], Diary('1', createdAt));
// 追加読み込み
mockGetDiaries = Future(() => [Diary('2', createdAt)]);
await controller.getDiaries();
state = controller.debugState;
expect(state.diaries.length, 2);
expect(state.diaries[0], Diary('1', createdAt));
expect(state.diaries[1], Diary('2', createdAt));
});
controller
の取得
final controller = container
.listen(
diariesControllerProvider.notifier.select((value) => value),
(previous, next) {},
)
.read();
まず、最初のところですが、テスト対象のDiariesController
を取得しています。
DiariesController
の Provider
は、 AutoDisposeStateNotifierProvider<DiariesController, DiariesPageState> diariesControllerProvider
なのですが、AutoDisposeかつStateNotifierProviderの場合は、下記のような感じでユニットテストではcontroller
を取得できるようです。
この辺はググってもあまり情報が見つけれらなかったので、デファクトスタンダートな方法がわかれば更新していこうと思います...🙏
メソッドのmock
final createdAt = DateTime.now();
// 初回読み込み
mockGetDiaries = Future(() => [Diary('1', createdAt)]);
await controller.getDiaries(isInit: true);
最初のmockGetDiaries = Future(() => [Diary('1', createdAt)]);
で controller.getDiaries()
した時の戻り値を決定しています。
初回読み込み時のstate検証
DiariesPageState state = controller.debugState;
expect(state.diaries.length, 1);
expect(state.diaries[0], Diary('1', createdAt));
controller.debugState
を使って、先ほどのメソッド実行で設定されたstateを取得しています。debugState
は下記のような説明の通りユニットテストで利用するっぽいです。
Containing class: StateNotifier
A development-only way to access state outside of StateNotifier.
The only difference with state is that debugState is not "protected".\ Will not work in release mode.
This is useful for tests.
追加読み込み時のstate検証
初回読み込みしたstate
を維持したまま追加読み込みされていたかのテストを書いてます。
// 追加読み込み
mockGetDiaries = Future(() => [Diary('2', createdAt)]);
await controller.getDiaries();
state = controller.debugState;
expect(state.diaries.length, 2);
expect(state.diaries[0], Diary('1', createdAt));
expect(state.diaries[1], Diary('2', createdAt));
追加読み込みのテスト用に mockGetDiaries
を指定して、await controller.getDiaries();
を実行して、 controller.debugState
を取得し直して、state
内容を検証しています。
エラー系test
test('リスト取得でエラーの場合にExceptionがstateに設定されていること', () async {
final controller = container
.listen(
diariesControllerProvider.notifier.select((value) => value),
(previous, next) {},
)
.read();
mockGetDiaries = Future.error(Exception('error'));
await controller.getDiaries();
final state = controller.debugState;
expect(state.exception, isException);
expect((state.exception as AppException).message, '一覧の取得に失敗しました');
});
mockGetDiaries = Future.error(Exception('error'));
で controller.getDiaries()
実行後にエラーがstate
に設定されるようにしています。正常系のテストと同じようにcontroller.debugState
を取得してstate
にエラーメッセージが設定されたかを検証しています。
Discussion