[読書会]Riverpod.dev/docs 翻訳及び補足(Guides)

Riverpod.dev/docs(開始URL: https://riverpod.dev/docs/introduction/why_riverpod )には日本語訳ページ(/ja)も部分的には既にあるのですが、その部分はそのまま参照して、その他の英語訳のままのページについては自力で翻訳および解説を補足してみました。まだ工事中ですが最終的に満足ができたら上記サイトの翻訳ページに献上できないかな等という目標もあります。

本ブログの(上記Riverpod.dev/docsの)翻訳および解説の目次頁(構成上のトップページ)は次のURLになります。

本頁は、上記のうちの、「Guids」の章の翻訳および解説頁になります。

注釈)現在Riverpod.dev/docs中の本章「Guids」には単体テストについての記載のみです。

テスト

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

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

  • test もしくは testWidgets の間でステート(状態)を共有しない。 グローバルステートは持たず、持つとしても各テスト実施後にすべてリセットする。
  • モッキングあるいはプロバイダのオーバーライドを通じて、強制的にプロバイダに特定のステートを持たせることができる。

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

test/testWidget 間で状態を保持する必要はありません。

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

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

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

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

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

testWidgets (Flutter)の場合:

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

参考) widget_test.dart による単体テスト

少し手前のサンプルアプリ中の、product_list_view.dart中の、ProductListView()ウィジェットをテスト対象とした、widget_test.dartによるテスト実施例です。

(product_list_view.dart)テスト対象:ProductListView() ウィジェット:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'product.dart';

class ProductListView extends ConsumerWidget {
  const ProductListView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    List<Product> products = ref.watch(productsProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Products'),
        actions: [
          DropdownButton<ProductSortType>(
            key: const Key('sortButton'), //テスト用
            value: ref.watch(productSortTypeProvider),
            onChanged: (ProductSortType? value) { //テスト用(ProductSorType? を付加)
              if (value != null) {
                ref.read(productSortTypeProvider.notifier).state = value;
              }
            },
            items: const [
              DropdownMenuItem(
                value: ProductSortType.name,
                child: Icon(Icons.sort_by_alpha),
              ),
              DropdownMenuItem(
                value: ProductSortType.price,
                child: Icon(Icons.sort),
              ),
            ],
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text('${product.price} \$'),
          );
        },
      ),
    );
  }
}
(widget_test.dart)テストコード例:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_start_04/product_list_view.dart';  // 必要に応じてパスを調整してください

void main() {
  // テストにProviderScopeを組み込む
  Widget createTestWidget(Widget child) {
    return ProviderScope(
      child: MaterialApp(
        home: child,
      ),
    );
  }

  testWidgets('Product list sorts correctly by price', (WidgetTester tester) async {
    //pumpWidget(): テスト対象のウィジェットをレンダリングする
    //(初期状態で正しいデータ(商品リスト)が表示されているか確認する)
    await tester.pumpWidget(createTestWidget(const ProductListView()));

    await tester.pumpAndSettle(); // アニメーションが完了するのを待つ

    //ドロップダウンボタンをタップ(1回目)
    //await tester.tap(find.byType(DropdownButton)); ←なんか失敗したので、Keyを設定して認識させるようにした
    await tester.tap(find.byKey(const Key('sortButton')));
    await tester.pumpAndSettle(); // ドロップダウンメニューが開くのを待つ

    //価格順にソートするオプションをタップ
    await tester.tap(find.byIcon(Icons.sort).last);
    await tester.pumpAndSettle(); // ソート処理が完了するのを待つ

    //ソート後の最初の商品が期待する商品であるかを検証
    expect(find.text('cookie'), findsWidgets);
    expect(find.text('2.0 \$'), findsWidgets);

    //他の商品も正しい順序で表示されているかを検証
    final productNames = tester.widgetList(find.byType(ListTile)).map((tile){
      return (tile as ListTile).title as Text;
    }).toList();

    //商品名でソートされていることを確認
    expect(productNames[0].data, equals('cookie'));
    expect(productNames[1].data, equals('ps5'));
    expect(productNames[2].data, equals('iPhone'));

    //他の価格も正しい順序で表示されているかを検証
    final productPrices = tester.widgetList(find.byType(ListTile)).map((tile) {
      return (tile as ListTile).subtitle as Text;  // `subtitle`を使用して価格を取得
    }).toList();

    //価格でソートされていることを確認(ここでは昇順と想定)
    expect(productPrices[0].data, equals('2.0 \$'));  // 最低価格
    expect(productPrices[1].data, equals('500.0 \$'));  // 中間価格
    expect(productPrices[2].data, equals('999.0 \$'));  // 最高価格

  });
}
(ターミナル出力結果)テスト結果例:
//(1回目)
riverpod_start_04>flutter test test/widget_test.dart
00:03 +1: All tests passed!  //成功時メッセージ

//(2回目)
C:\app\flutter\09_new\08\06\riverpod_start_04>flutter test test/widget_test.dart
00:03 +0: Product list sorts correctly by price
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞══
The following TestFailure was thrown running a test:
Expected: '2 $'
  Actual: '2.0 $'
   Which: is different.
          Expected: 2 $
            Actual: 2.0 $
                     ^
           Differ at offset 1

When the exception was thrown, this was the stack:
#4      main.<anonymous closure> (file:///C:/riverpod_start_04/test/widget_test.dart:52:5)
<asynchronous suspension>
#5      testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:189:15)
<asynchronous suspension>
#6      TestWidgetsFlutterBinding._runTestBody (package:flutter_test/src/binding.dart:1032:5)
<asynchronous suspension>
<asynchronous suspension>
(elided one frame from package:stack_trace)

This was caught by the test expectation on the following line:
  file:///C:/riverpod_start_04/test/widget_test.dart line 52
The test description was:
  Product list sorts correctly by price
════════════════════════════════════════════════════════════════════
00:03 +0 -1: Product list sorts correctly by price [E]
  Test failed. See exception logs above.
  The test description was: Product list sorts correctly by price


To run this test again: C:\dev\flutter\bin\cache\dart-sdk\bin\dart.exe test C:/app/flutter/09_new/08/06/riverpod_start_04/test/widget_test.dart -p vm --plain-name "Product list sorts correctly by price"   
00:03 +0 -1: Some tests failed.

//(3回目)
C:\riverpod_start_04>flutter test test/widget_test.dart
00:03 +1: All tests passed! //成功時メッセージ

上記テストコード内容の詳細は‘Testing & debugging’ 参照。

テスト中のプロバイダーの動作をオーバーライドします。

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

  1. 型安全でシンプルなAPIを提供し、HTTP リクエストを実行する Repository オブジェクト。
  2. アプリのステートを管理し、Repository を使って様々な条件をもとに HTTP リクエストを実行するオブジェクト(これは ChangeNotifier や Bloc、時にはプロバイダだったりします)。

Riverpod を使う場合、これらのオブジェクトは次のように表すことができます。

(Repositoryオブジェクト)これの挙動をオーバーライドしたい:

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 の挙動をオーバーライドします。

(widget_test.dart)ここで、オーバーライドする:

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

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

備考)

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

Dart

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 修飾子付きのプロバイダをオーバーライドするには、通常と少し異なる構文を使う必要があります。

次のようなプロバイダがあるとします。

Dart
final response = ref.watch(myProvider('12345'));

この場合は以下の通り、値をオーバーライドする必要があります。

Dart
myProvider('12345').overrideWithValue(...));

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

最後に、Flutter テストの完全なコード全体を示します。

Dart

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 {
  @override
  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;
  @override
  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),
    ]);
  });
}

コメントを残す