Skip to main content

Testing

中規模から大規模のアプリケーションでは、アプリケーションをテストすることが重要です。

アプリケーションのテストを成功させるためには、以下のものが必要です。

  • test/testWidgetsの間では、状態を保存してはいけません。
    これは、グローバルな状態がない、またはすべてのグローバルな状態が各テストの後にリセットされるべきであることを意味します。

  • モックを使ったり、目的の状態になるまで操作したりして、
    プロバイダに特定の状態を強制することができます。

Riverpodがこれらをどのようにサポートするのか、一つずつ見ていきましょう。

test/testWidgetsの間では、状態を保存してはいけません#

プロバイダは通常、グローバル変数として宣言されているので、その点は心配ないでしょう。
結局のところ、グローバルステートはテストを非常に難しくしています。
なぜならば、長い setUp/tearDown が必要になるからです。

しかし、現実には、プロバイダはグローバルに宣言されていますが、プロバイダの状態はグローバルではありません

その代わりに、ProviderContainerというオブジェクトに格納されています。
このオブジェクトは、Dartのみの例をご覧になった方はご存知かもしれません。
まだご覧になっていない方は、このProviderContainerオブジェクトは、
私たちのプロジェクトでRiverpodを有効にするウィジェットである、
ProviderScopeによって暗黙のうちに作成されていることを知っておいてください。

具体的には、プロバイダを利用している2つのtestWidgetsは、状態を共有しないということです。
そのため、SetUp / TearDownは一切必要ありません。

長々とした説明よりも、例えがあった方がいいですね。

// Flutterで実装・テストされたカウンター
// グローバルにプロバイダを宣言し、それを2つのテストで使用して、
// テストの間に状態が正しく `0` にリセットされるかどうかを確認してみましょう。
final counterProvider = StateProvider((ref) => 0);
// 現在の状態と、状態を増加させるためのボタンをレンダリングします。
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Consumer(builder: (context, ref, _) {
final counter = ref.watch(counterProvider);
return RaisedButton(
onPressed: () => counter.state++,
child: Text('${counter.state}'),
);
}),
);
}
}
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(RaisedButton));
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()));
// 状態は再び「0」となり、tearDown/setUp は必要ありません。
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
}

ご覧のとおり、counterProviderはグローバルとして宣言されていますが、テスト間で状態は共有されていません。
このように、テストは完全に分離して実行されるため、 異なる順序で実行された場合にテストの動作が異なる可能性があることを心配する必要はありません。

テスト時にProviderの動作をオーバーライドする。#

一般的なアプリケーションでは、次のようなオブジェクトがあります。

  • Repositoryクラスは、HTTPリクエストを実行するための型安全でシンプルなAPIを提供します。

  • アプリケーションの状態を管理するオブジェクトで、 Repositoryを使用してさまざまな要因に基づいてHTTPリクエストを実行することがあります。 これは、ChangeNotifierBloc、あるいはProviderかもしれません。

Riverpodを使って、これを次のように表すことができます。

class Repository {
Future<List<Todo>> fetchTodos() async {}
}
// RepositoryのインスタンスをProviderで公開します。
final repositoryProvider = Provider((ref) => Repository());
/// TODOのリストです。
/// ここでは、[Repository]を使ってサーバーからTODOリストを取得しているだけで、他には何もしていません。
final todoListProvider = FutureProvider((ref) async {
// Repositoryインスタンスの取得
final repository = ref.read(repositoryProvider);
// TODOを取得し、UIに公開します。
return repository.fetchTodos();
});

このような状況で、ユニットテストやウィジェットテストを行う際には、
通常、Repositoryのインスタンスを、実際のHTTPリクエストの代わりに、
あらかじめ定義されたレスポンスを返す偽の実装に置き換えたいと思うでしょう。

次に、todoListProvider(またはそれに相当するもの)がRepositoryのモックされた実装を使用するようにします。

これを実現するには、ProviderScope/ProviderContaineroverridesパラメータを使用して、
repositoryProviderの動作をオーバーライドします。

testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// Repositoryの代わりにFakeRepositoryを返すように、
// repositoryProviderの動作をオーバーライドします。
repositoryProvider.overrideWithProvider(Provider((ref) => FakeRepository()))
// `todoListProvider`をオーバーライドする必要はありません。
// オーバーライドされたrepositoryProviderが自動的に使用されます。
],
child: MyApp(),
),
);
}

ハイライトされたコードを見ればわかるように、ProviderScope/ProviderContainerは、
プロバイダの実装を別の動作に置き換えることができます。

info

一部のプロバイダーは、その動作をオーバーライドするための簡易な方法を公開しています。
例えば、FutureProviderでは、AsyncValueでプロバイダをオーバーライドすることができます。

final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
ProviderScope(
overrides: [
/// FutureProviderをオーバーライドして固定値を返せるようにする
todoListProvider.debugOverrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: MyApp(),
);

完全なウィジェットのテスト例#

最後に、今回のFlutterテストの全コードを紹介します。

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のインスタンスをProviderで公開します。
final repositoryProvider = Provider((ref) => Repository());
/// TODOのリストです。
/// ここでは、[Repository]を使ってサーバーからTODOリストを取得しているだけで、他には何もしていません。
final todoListProvider = FutureProvider((ref) async {
// リポジトリインスタンスの取得
final repository = ref.read(repositoryProvider);
// TODOを取得し、UIに公開します。
return repository.fetchTodos();
});
/// 事前に定義されたTODOリストを返す、Repositoryのモックされた実装です。
class FakeRepository implements Repository {
@override
Future<List<Todo>> fetchTodos() async {
return [
Todo(id: '42', label: 'Hello world', completed: false),
];
}
}
class TodoItem extends StatelessWidget {
const TodoItem({Key? key, required this.todo}) : super(key: key);
final Todo todo;
@override
Widget build(BuildContext context) {
return Text(todo.label);
}
}
void main() {
testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
repositoryProvider.overrideWithProvider(Provider((ref) => FakeRepository()))
],
// このアプリケーションは、TodoリストをtodoListProviderから読み込んで、表示します。
// これをMyAppウィジェットに抽出することができます。
child: MaterialApp(
home: Scaffold(
body: Consumer(builder: (context, ref, _) {
final todos = ref.watch(todoListProvider);
// TODOリストが読み込まれているか、エラーになっている
if (todos.data == null) {
return const CircularProgressIndicator();
}
return ListView(
children: [
for (final todo in todos.data.value) TodoItem(todo: todo)
],
);
}),
),
),
),
);
// 最初のフレームはロード状態です。
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 再レンダリングします。TodoListProviderは、そろそろTodosの取得を終えているはずです。
await tester.pump();
// 読み込みはすでに終了しています。
expect(find.byType(CircularProgressIndicator), findsNothing);
// FakeRepositoryから返されたデータを使って、1つの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),
]);
});
}