メインコンテンツに進む

テスト

中〜大規模のアプリにおいてテストは重要な工程です。

Riverpod で正しくテストを実施するには、以下のポイントを実現する必要があります。

  • test もしくは testWidgets の間でステート(状態)を共有しない。 グローバルステートは持たず、持つとしても各テスト実施後にすべてリセットする。

  • モッキングあるいはプロバイダのオーバーライドを通じて、強制的にプロバイダに特定のステートを持たせることができる。

Riverpod の機能をどう活用できるか、一つ一つ見ていきましょう。

test もしくは testWidgets の間でステートを共有しない

通常プロバイダはグローバル変数として定義されるため、この点が心配になる人もいるかもしれません。 グローバルステートは面倒な setUptearDown が必要になることがあるため、テストを厄介なものにしがちです。

しかし、Riverpod ではプロバイダがグローバルで定義されたとしても、ステート自体は グローバルではありません

ステートは ProviderContainer というオブジェクトに格納されています。 Dart のみのサンプルコードでこのオブジェクトを見かけた人もいるかもしれません。 この ProviderContainer オブジェクトは ProviderScopeRiverpod を使うためにウィジェットツリーに挿入するウィジェット)によって暗黙的に生成されます。

ステートがグローバルではないということは、そのプロバイダを利用する2つの testWidget の間でステートは共有されないということです。 そのため、setUptearDown を設定する必要性は全くないのです。

言葉での説明より実際のサンプルコードの方が多くを語ると思いますので、以下でご紹介します。

// 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);
});
}

この通り、counterProvider がグローバルに宣言されている一方で、テスト間でステートは共有されていません。 それぞれのテストは互いに独立した環境で実施されるため、実施順序によってテスト結果が異なることを心配する必要もありません。

プロバイダの挙動をオーバーライドする

現実のアプリでは次のようなオブジェクトを持つことが多いかと思います。

  • 型安全でシンプルなAPIを提供し、HTTP リクエストを実行する Repository オブジェクト。

  • アプリのステートを管理し、Repository を使って様々な条件をもとに HTTP リクエストを実行するオブジェクト(これは ChangeNotifierBloc、時にはプロバイダだったりします)。

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 あるいは ProviderContaineroverrides パラメータを使って、 repositoryProvider の挙動をオーバーライドします。

testWidgets('override repositoryProvider', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
// repositoryProvider の挙動をオーバーライドして
// Repository の代わりに FakeRepository を戻り値とする
repositoryProvider.overrideWithValue(FakeRepository())
// `todoListProvider` はオーバーライドされた repositoryProvider を
// 自動的に利用することになるため、オーバーライド不要
],
child: MyApp(),
),
);
});

上記のハイライト行の通り、ProviderScope あるいは ProviderContainer を使用して repositoryProvider に指定の値を持たせることができました。

info

プロバイダによっては、挙動をオーバーライドする際に指定する値の型が特殊な場合があります。 例えば、FutureProviderAsyncValue オブジェクトを指定する必要があります。

final todoListProvider = FutureProvider((ref) async => <Todo>[]);
// ...
ProviderScope(
overrides: [
/// FutureProvider をオーバーライドして固定のステートを返す
todoListProvider.overrideWithValue(
AsyncValue.data([Todo(id: '42', label: 'Hello', completed: true)]),
),
],
child: const MyApp(),
);
info

.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({Key? key, required this.todo}) : super(key: key);
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),
]);
});
}