【Flutter】Riverpodを使う場合の単体テストとウィジェットテストのやり方

FlutterでRiverpodを使う場合の単体テストウィジェットテストのやり方についてまとめます。

Flutter 3.19.4
flutter_riverpod 2.5.1

はじめに

本記事ではFlutterでRiverpodを使う場合の単体テストウィジェットテストのやり方についてまとめます。
Riverpodの基本的な使い方については割愛しますが、以下の記事にまとめていますので必要に応じて参照してください。

light11.hatenadiary.com

単体テスト(Providerのテスト)

まずProviderの単体テストを書く方法をみていきます。
以下の簡単なProividerを定義し、コード生成しておきます。

// count.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count.g.dart';

@riverpod
class Count extends _$Count {
  @override
  int build(int initialValue) {
    return initialValue;
  }

  void add(int value) {
    state = state + value;
  }
}

次にこのProviderの単体テストを書いていきます。
テストの際には、以下のようにProviderContainerを作成し、それを通してProviderの状態を取得します。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_flutter/count.dart';

void main() {
  test('Some description', () {
    // ProviderContainerを作る
    // 状態がキャッシュされるため、テスト間でProviderContainerを共有しないようにテストごとに作成&破棄
    final container = createContainer();

    expect(
      // ProviderContainerを通して状態を取得
      container.read(countProvider(123)),
      equals(123),
    );

    // ProviderContainerを通して状態を変更
    container.read(countProvider(123).notifier).add(1);

    // 変更後の状態をテスト
    expect(
      container.read(countProvider(123)),
      equals(124),
    );
  });
}

/// ProviderContainerを作成するユーティリティ(ボイラープレート)
ProviderContainer createContainer({
  ProviderContainer? parent,
  List<Override> overrides = const [],
  List<ProviderObserver>? observers,
}) {
  final container = ProviderContainer(
    parent: parent,
    overrides: overrides,
    observers: observers,
  );

  // テスト終了時にProviderContainerを破棄
  addTearDown(container.dispose);

  return container;
}

細かい説明はコメントを参照してください。

なお、Providerが非同期の場合は以下のように値を取得します。

var value = await container.read(countProvider(123).future);

readとlistenの使い分け

さて、Riverpodにおいて、Providerの自動破棄を有効(デフォルトは有効)にしていると、1フレームの間そのProviderが監視されていなければ状態が破棄されます。
これはテスト中にも言えることで、以下のように間に待機を挟むと状態が破棄されてしまいます。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_flutter/count.dart';

void main() {
  test('Some description', () async {
    final container = createContainer();
    
    // 初期状態である123を124に更新しておく
    container.read(countProvider(123).notifier).add(1);
    expect(
      container.read(countProvider(123)),
      equals(124),
    );

    // 1秒待機
    await Future.delayed(const Duration(seconds: 1));

    // 状態が破棄されて初期状態(123)が得られることを確認
    expect(
      container.read(countProvider(123)),
      equals(123),
    );
  });
}

ProviderContainer createContainer({
  ProviderContainer? parent,
  List<Override> overrides = const [],
  List<ProviderObserver>? observers,
}) {
  final container = ProviderContainer(
    parent: parent,
    overrides: overrides,
    observers: observers,
  );

  addTearDown(container.dispose);

  return container;
}

これを防ぐためには、監視を行わないread()の代わりに、以下のようにlisten()を使用します。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_flutter/count.dart';

void main() {
  test('Some description', () async {
    final container = createContainer();
    // readではなくlistenを使う
    final subscription = container.listen<int>(countProvider(123), (_, __) {});

    container.read(countProvider(123).notifier).add(1);
    expect(
      // subscription.read()で値を取得する
      subscription.read(),
      equals(124),
    );

    // 1秒待機
    await Future.delayed(const Duration(seconds: 1));

    // 状態が初期化されていないことを確認
    expect(
      subscription.read(),
      equals(124),
    );
    
    // 購読解除
    // どうせProviderContainerはdisposeされるので別に書かなくてもいいけど
    subscription.close();
  });
}

ProviderContainer createContainer({
  ProviderContainer? parent,
  List<Override> overrides = const [],
  List<ProviderObserver>? observers,
}) {
  final container = ProviderContainer(
    parent: parent,
    overrides: overrides,
    observers: observers,
  );

  addTearDown(container.dispose);

  return container;
}

ちなみにlisten()の第二引数を使うと、以下のように状態が変更された時のコールバックを登録できます。

final subscription = container.listen<int>(
  countProvider(123),
  (previous, next) {
    print('previous: $previous, next: $next');
  },
);

ウィジェットテスト

次にウィジェットテストを作成します。

まず以下のようにテスト用に適当なウィジェットを作成しておきます。

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'count.dart';

void main() {
  runApp(
    const ProviderScope(child: Home()),
  );
}

class Home extends StatelessWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Consumer(
        builder: (context, ref, _) {
          final count = ref.watch(countProvider(123));
          return Scaffold(
            appBar: AppBar(title: const Text('Example')),
            body: Center(
              child: Text('$count'),
            ),
            floatingActionButton: FloatingActionButton(
              onPressed: () {
                ref.read(countProvider(123).notifier).add(1);
              },
              child: const Icon(Icons.add),
            ),
          );
        },
      ),
    );
  }
}

実行結果は以下の通りです。
シンプルにint型の値を増加させるだけのアプリケーションです。

ウィジェットテスト

このウィジェットをテストするには、以下のようにpumpWidgetでテスト対象のウィジェットを渡す際に、ProviderScopeでラップします。
これにより Riverpod によるDIが行われ、テストにおいてもProviderが正常に動作します。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_flutter/main.dart';

void main() {
  testWidgets('foo', (tester) async {
    // pumpWidgetでテスト対象のウィジェットを渡す際に、ProviderScopeでラップする
    await tester.pumpWidget(
      const ProviderScope(child: Home()),
    );

    // 画面更新前のカウント数をテスト
    expect(find.text('123'), findsOneWidget);

    // addボタンをタップ
    await tester.tap(find.byIcon(Icons.add));

    // 画面更新
    await tester.pump();

    // 画面更新後のカウント数をテスト
    expect(find.text('124'), findsOneWidget);
  });
}

Providerのモックを使う

例えばPrioviderで通信処理を行っている場合など、テスト時には通信を行わずにダミーの通信結果を返すモックに差し替えたいケースがあります。
このようなケースに対応するため、RiverpodにProviderは上書きすることができます。

この動作を見るためにまずは以下のようなProviderを作成します。

// count.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'count.g.dart';

@riverpod
class Count extends _$Count {
  int _multiplier;

  // multiplierをセットすると値が2倍になる
  set multiplier(int value) {
    _multiplier = value;
  }

  Count() : _multiplier = 1;

  @override
  int build(int initialValue) {
    return initialValue * _multiplier;
  }

  void add(int value) {
    state = state + value * _multiplier;
  }
}

次にこれのテストを書き、Providerをモックに差し替えます。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_flutter/count.dart';

void main() {
  test('foo', () {
    final container = createContainer(
      overrides: [
          // Providerをモック用のものにオーバーライド
        countProvider(123).overrideWith(() => Count()..multiplier = 2),
      ],
    );

    expect(
      // Providerの値を取得
      container.read(countProvider(123)),
      // multiplierが2のProviderに差し代わっているので、2倍の値になる
      equals(246),
    );

    container.read(countProvider(123).notifier).add(1);

    expect(
      container.read(countProvider(123)),
      // multiplierが2のProviderに差し代わっているので、2倍の値になる
      equals(248),
    );
  });
}

ProviderContainer createContainer({
  ProviderContainer? parent,
  List<Override> overrides = const [],
  List<ProviderObserver>? observers,
}) {
  final container = ProviderContainer(
    parent: parent,
    overrides: overrides,
    observers: observers,
  );

  addTearDown(container.dispose);

  return container;
}

今回は適当にmultiplierを書き換えているだけですが、通信処理のモックを作りたければ、Providerに与える通信処理を担当するオブジェクトを差し替えればOKです。

Providerの差し替えはウィジェットテストでも行うことができます。
以下は、前節のウィジェットテストで使ったウィジェットについて、この説で作ったProviderを使ってテストしている例です。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:test_flutter/count.dart';
import 'package:test_flutter/main.dart';

void main() {
  testWidgets('foo', (tester) async {
    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          // モック用のProviderを登録
          countProvider(123).overrideWith(() => Count()..multiplier = 2),
        ],
        child: const Home(),
      ),
    );

    // モック用のProviderではmultiplierが2倍になっているので、246が表示される
    expect(find.text('246'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    // モック用のProviderではmultiplierが2倍になっているので、248が表示される
    expect(find.text('248'), findsOneWidget);
  });
}

NotifierをMockingすることもできる(非推奨)

以下のようにしてNotifierをMockingすることもできます。

riverpod.dev

ただし以下のように書かれている通り推奨はされていません。

It is generally discouraged to mock Notifiers. This is because Notifiers cannot be instantiated on their own, and only work when used as part of a Provider.

本記事でもやり方については触れませんので、必要な方は公式ドキュメントを参照してください。

参考

riverpod.dev