テスト
中〜大規模のアプリにおいてテストは重要な工程です。
Riverpod で正しくテストを実施するには、以下のポイントを実現する必要があります。
test
もしくはtestWidgets
の間でステート(状態)を共有しない。 グローバルステートは持たず、持つとしても各テスト実施後にすべてリセットする。モッキングあるいはプロバイダのオーバーライドを通じて、強制的にプロバイダに特定のステートを持たせることができる。
Riverpod の機能をどう活用できるか、一つ一つ見ていきましょう。
test
もしくは testWidgets
の間でステートを共有しない
通常プロバイダはグローバル変数として定義されるため、この点が心配になる人もいるかもしれません。
グローバルステートは面倒な setUp
や tearDown
が必要になることがあるため、テストを厄介なものにしがちです。
しかし、Riverpod ではプロバイダがグローバルで定義されたとしても、ステート自体は グローバルではありません。
ステートは ProviderContainer というオブジェクトに格納されています。 Dart のみのサンプルコードでこのオブジェクトを見かけた人もいるかもしれません。 この ProviderContainer オブジェクトは ProviderScope (Riverpod を使うためにウィジェットツリーに挿入するウィジェット)によって暗黙的に生成されます。
ステートがグローバルではないということは、そのプロバイダを利用する2つの testWidget
の間でステートは共有されないということです。
そのため、setUp
や tearDown
を設定する必要性は全くないのです。
言葉での説明より実際のサンプルコードの方が多くを語ると思いますので、以下でご紹介します。
- testWidgets (Flutter)
- test (Dart のみ)
// Flutter により実装されたカウンターアプリのテスト
// グローバル定義したプロバイダを2つのテストで使用する
// テスト間でステートが正しく `0` にリセットされるかの確認
final counterProvider = StateProvider((ref) => 0);
// 現在のステートを表示し、その数字を増やす機能を持つボタンを描画
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
home: Consumer(builder: (context, ref, _) {
final counter = ref.watch(counterProvider);
return ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).state++,
child: Text('$counter'),
);
}),
);
}
}
void main() {
testWidgets('update the UI when incrementing the state', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));
// プロバイダ作成時に宣言した通りデフォルト値は `0`
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// ステートの数字を増やし、ボタンを再描画する
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// 増やしたステートの数字が正しく反映されているか
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
});
testWidgets('the counter state is not shared between tests', (tester) async {
await tester.pumpWidget(ProviderScope(child: MyApp()));
// ステートは共有されないため、tearDown/setUp がなくても `0` から
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
}
// Dart のみで実装されたカウンターアプリのテスト
// グローバル定義したプロバイダを2つのテストで使用する
// テスト間でステートが正しく `0` にリセットされるかの確認
final counterProvider = StateProvider((ref) => 0);
// mockito を使ってプロバイダによる Listener への通知を追跡する
class Listener extends Mock {
void call(int? previous, int value);
}
void main() {
test('defaults to 0 and notify listeners when value changes', () {
// プロバイダを利用するために必要なオブジェクト
// このオブジェクトはテスト間で共有しない
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
// プロバイダを監視して変化を検出する
container.listen<int>(
counterProvider,
listener.call,
fireImmediately: true,
);
// この時点で Listener はデフォルトの `0` で呼び出されているはず
verify(listener(null, 0)).called(1);
verifyNoMoreInteractions(listener);
// ステートの数字を増やす
container.read(counterProvider.notifier).state++;
// 今度は Listener が `1` で呼び出されているか確認
verify(listener(0, 1)).called(1);
verifyNoMoreInteractions(listener);
});
test('the counter state is not shared between tests', () {
// ProviderContainer を新たに作成してプロバイダを利用する
// これによりテスト間でステートが再利用されないことを保証できる
final container = ProviderContainer();
addTearDown(container.dispose);
final listener = Listener();
container.listen<int>(
counterProvider,
listener.call,
fireImmediately: true,
);
// このテストでもデフォルト値 `0` が使用されることを確認
verify(listener(null, 0)).called(1);
verifyNoMoreInteractions(listener);
});
}
この通り、counterProvider
がグローバルに宣言されている一方で、テスト間でステートは共有されていません。
それぞれのテストは互いに独立した環境で実施されるため、実施順序によってテスト結果が異なることを心配する必要もありません。
プロバイダの挙動をオーバーライドする
現実のアプリでは次のようなオブジェクトを持つことが多いかと思います。
型安全でシンプルなAPIを提供し、HTTP リクエストを実行する
Repository
オブジェクト。アプリのステートを管理し、
Repository
を使って様々な条件をもとに HTTP リクエストを実行するオブジェクト(これはChangeNotifier
やBloc
、時にはプロバイダだったりします)。
Riverpod を使う場合、これらのオブジェクトは次のように表すことができます。
class Repository {
Future<List<Todo>> fetchTodos() async => [];
}
// Repository インスタンスを公開するプロバイダ
final repositoryProvider = Provider((ref) => Repository());
/// Todo リストを公開するプロバイダ
/// [Repository] を使用して値をサーバから取得
final todoListProvider = FutureProvider((ref) async {
// Repository インスタンスを取得する
final repository = ref.watch(repositoryProvider);
// Todo リストを取得して、プロバイダを監視する UI 側に値を公開する
return repository.fetchTodos();
});
このシチュエーションでユニットあるいはウィジェットテストを作成する場合、
Repository
インスタンスをモックオブジェクトに置き換えて、あらかじめ定義されたレスポンスを返すことで
HTTP リクエストの代わりとするのが一般的かと思います。
そして todoListProvider
にこのモックオブジェクトの仮実装を使わせます。
これを Riverpod で行うには ProviderScope あるいは ProviderContainer の overrides
パラメータを使って、
repositoryProvider
の挙動をオーバーライドします。
- ProviderScope (Flutter)
- ProviderContainer (Dart のみ)
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// repositoryProvider の挙動をオーバーライドして
// Repository の代わりに FakeRepository を戻り値とする
repositoryProvider.overrideWithValue(FakeRepository())
// `todoListProvider` はオーバーライドされた repositoryProvider を
// 自動的に利用することになるため、オーバーライド不要
],
child: MyApp(),
),
);
});
test('override repositoryProvider', () async {
final container = ProviderContainer(
overrides: [
// repositoryProvider の挙動をオーバーライドして
// Repository の代わりに FakeRepository を戻り値とする
repositoryProvider.overrideWithValue(FakeRepository())
// `todoListProvider` はオーバーライドされた repositoryProvider を
// 自動的に利用することになるため、オーバーライド不要
],
);
// 初期ステートが loading であることを確認
expect(
container.read(todoListProvider),
const AsyncValue<List<Todo>>.loading(),
);
/// リクエストの結果が戻るのを待つ
await container.read(todoListProvider.future);
// 取得したデータを公開する
expect(container.read(todoListProvider).value, [
isA<Todo>()
.having((s) => s.id, 'id', '42')
.having((s) => s.label, 'label', 'Hello world')
.having((s) => s.completed, 'completed', false),
]);
});
上記のハイライト行の通り、ProviderScope あるいは ProviderContainer
を使用して repositoryProvider
に指定の値を持たせることができました。
プロバイダによっては、挙動をオーバーライドする際に指定する値の型が特殊な場合があります。
例えば、FutureProvider は AsyncValue
オブジェクトを指定する必要があります。
final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
/* SKIP */
final foo =
/* SKIP END */
ProviderScope(
overrides: [
/// FutureProvider をオーバーライドして固定のステートを返す
todoListProvider.overrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: const MyApp(),
);
.family
修飾子付きのプロバイダをオーバーライドするには、通常と少し異なる構文を使う必要があります。
次のようなプロバイダがあるとします。
final response = ref.watch(myProvider('12345'));
この場合は以下の通り、値をオーバーライドする必要があります。
myProvider('12345').overrideWithValue(...));
ウィジェットテストのサンプルコードまとめ
以上の内容をまとめたウィジェットテストのサンプルコードです。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
class Repository {
Future<List<Todo>> fetchTodos() async => [];
}
class Todo {
Todo({
required this.id,
required this.label,
required this.completed,
});
final String id;
final String label;
final bool completed;
}
// Repository インスタンスを公開するプロバイダ
final repositoryProvider = Provider((ref) => Repository());
/// Todo リストを公開するプロバイダ
/// [Repository] を使用して値をサーバから取得
final todoListProvider = FutureProvider((ref) async {
// Repository インスタンスを取得する
final repository = ref.read(repositoryProvider);
// Todo リストを取得して、プロバイダを監視する UI 側に値を公開する
return repository.fetchTodos();
});
/// あらかじめ定義した Todo リストを返す Repository のフェイク実装
class FakeRepository implements Repository {
Future<List<Todo>> fetchTodos() async {
return [
Todo(id: '42', label: 'Hello world', completed: false),
];
}
}
class TodoItem extends StatelessWidget {
const TodoItem({super.key, required this.todo});
final Todo todo;
Widget build(BuildContext context) {
return Text(todo.label);
}
}
void main() {
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
repositoryProvider.overrideWithValue(FakeRepository())
],
// todoListProvider の値を監視して Todo リストを表示するアプリ
// 以下を抽出して MyApp ウィジェットとしても可
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// Todo リストのステートが loading か error の場合
if (todos.asData == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.asData!.value) TodoItem(todo: todo)
],
);
}),
),
),
),
);
// 最初のフレームのステートが loading になっているか確認
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 再描画。このあたりで TodoListProvider は 値の取得が終わっているはず
await tester.pump();
// loading 以外のステートになっているか確認
expect(find.byType(CircularProgressIndicator), findsNothing);
// FakeRepository が公開した値から TodoItem が一つ描画されているか確認
expect(tester.widgetList(find.byType(TodoItem)), [
isA<TodoItem>()
.having((s) => s.todo.id, 'todo.id', '42')
.having((s) => s.todo.label, 'todo.label', 'Hello world')
.having((s) => s.todo.completed, 'todo.completed', false),
]);
});
}