🎼

RiverpodのStateNotifierProvider用のテストを書いた

2022/07/19に公開

StateNotifierProviderを使ったWidgetのテストを書こうとしたところ、少し詰まったのでどういって解消したのか書きます。

全般的なコードの構成

まず前提として、どのようなクラスの構成で書いていたのかを説明します。

はじめはテストのことは意識していなかったので、以下のようなコードを書きました。モデルデータとStateNotifierを使ったベーシックな構成です。

まずStateNotifierで監視するための、モデルクラスを作ります。やっぱcopyWithが便利だし Freezed を使う場合が多いのかなと思います。

part 'score.freezed.dart';


class Score with _$Score {
  factory Score({
    (0) int point1,
    (0) int point2,
  }) = _Score;
}

次に、このScoreクラスを使うStateNotifier,StateNotifierProviderを作ります。StateNotifierの中で、Readerを使いたかったのでref.readを渡しています。メソッドなんかは適当です。

final scoreProvider =
    StateNotifierProvider<ScoreNotifier, Score>((ref) {
  return ScoreNotifier(ref.read);
});

class ScoreNotifier extends StateNotifier<Score> {
  final Reader _read;
  ScoreNotifier(this._read) : super(Score(point1: 0, point2: 0));

  void add1() {
    state = state.copyWith(point1: state.point1 + 1);
  }

  void add2() {
    state = state.copyWith(point2: state.point2 + 1);
  }
}

Widgetで表示させるなら、このようになるでしょうか。

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final stateProvider = ref.watch(scoreProvider);
    return Column(
      children: [
        Text("Score: ${stateProvider.point1}"),
        ElevatedButton(
          onPressed: () => ref.read(scoreProvider.notifier).add1(),
          child: const Text("add1"),
        ),
      ],
    );
  }
}

テストが書けない

さて、上記のコードを使って 「scoreProvider内のScoreNotifierのstateの値が正しくScoreWidgetに表示されるのか」をテストしたいと思いました。

Riverpodのドキュメントを読むと、Providerをオーバーライドして別のProviderを読み込ませる方法が記述してあり、これを使えばいけそうに感じます。

https://riverpod.dev/ja/docs/cookbooks/testing

しかしながら、自分が書いたようなコードだと「ScoreNotifierクラス内のstate(Scoreクラス)の値を特定の値に変更する」ことがすんなりうまくいきません。overrideWithValueをするために、ScoreNotifierのインスタンスが必要なのですが、引数で必要なReaderがどこにもありません。たとえば、Readerが必要なければ、以下のような方法でもいけるはずですなんですが。

var value = ScoreNotifier();
value.add1();
await tester.pumpWidget(
  ProviderScope(
    overrides: [ scoreProvider.overrideWithValue(value) ],
    child: MyApp(),
  ),
);

とはいえstateの値を任意にできないし、なかなか不便です。

ここでDIが!

困ったなと思って検索していたら、同じように悩んでいる人がいました。簡単に言うとStateNotifierの初期化時に初期値としてstateを渡せるようにしておき、そこをオーバーライドしちゃえばいいとのこと。ようするにDependency Injectionバンザイということですね!!

https://stackoverflow.com/questions/70011054/riverpod-testing-how-to-mock-state-with-statenotifierprovider

この対応は簡単です。ScoreNotifier側で引数を取るようにして、scoreProviderまわりを以下のように変更するだけです。StateNotifierは基本的にはProviderにしか依存してないはずなので、コードの変更は最小限で済みます。最高ですね。

final scoreProvider =
    StateNotifierProvider<ScoreNotifier, Score>((ref) {
  return ScoreNotifier(ref.read, Score(point1: 0, point2: 0));
});

class ScoreNotifier extends StateNotifier<Score> {
  final Reader _read;
  ScoreNotifier(this._read, Score state) : super(state);

  void add1() {
    state = state.copyWith(point1: state.point1 + 1);
  }
}

ScoreNotifierに初期stateを渡せるようになったので、scoreProviderと同等で且つ別のstateを持ったプロバイダーが作れるようになりました。テスト時にoverrideWithProviderをしたら、自作したproviderを使ってくれます。

testWidgets('score provider test', (WidgetTester tester) async {
  final otherProvider =
      StateNotifierProvider<ScoreNotifier, Score>((ref) {
    return ScoreNotifier(ref.read, Score(point1: 100, point2, 200));
  });

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        scoreProvider.overrideWithProvider(otherProvider),
      ],
      child: targetWidget(),
    ),
  );

  expect(find.text("100"), findsOneWidget);
});

これでうまくいきました。

もうちょっとシンプルなパターンもある

さんざんReaderがどうのって書いてきましたが、Readerを使わないケースのほうが多そうです。Readerを使わなければ、overrideWithValueが使えるので、コードがもう少しシンプルになりますね。

final scoreProvider =
    StateNotifierProvider<ScoreNotifier, Score>((ref) {
  return ScoreNotifier(Score(point1: 0, point2: 0));
});

class ScoreNotifier extends StateNotifier<Score> {
  ScoreNotifier(Score state) : super(state);

  void add1() {
    state = state.copyWith(point1: state.point1 + 1);
  }
}

testWidgets('score provider test', (WidgetTester tester) async {
  final value = ScoreNotifier(ref.read, Score(point1: 100, point2, 200));

  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        scoreProvider.overrideWithValue(value),
      ],
      child: targetWidget(),
    ),
  );

  expect(find.text("100"), findsOneWidget);
});

おしまい

Flutterはまだ使いはじめたばかりで、もっと深く理解していくとまた違う感想がでてくるかもしれませんが、いまのところ開発環境としてかなり整っているなと感じており、コードが書きやすいです。

テストに関しては、RiverpodやWidgetツリーという概念のおかげで、機能ごとのテストが書きやすいように感じています。Widgetまわりのテストでは、ネイティブと違って実際の画面を使わずに動作するので、トラブルが少ないんじゃないだろうかと想像しています。ゆえに、気軽に「このWidgetが壊れてないかだけチェックしておこ」といったことがやりやすいです。

実際個人的にアプリ開発を始めてみて、色々触っていくうちに理解できることも増えていってます。まだまだ世界観の一部にしか触れられてないでしょうから、今後もっと色んな体験を深めていきたいですね。Flutterすごく面白いです。

GitHubで編集を提案

Discussion