🐨

【Flutter Test】mocktail Tips集

2022/11/16に公開

はじめに

Flutter Unitテスト、難しいと思うのは僕だけでしょうか。

中でも、Riverpodの各Providerが持つクラスをmocktailでモック化してテストを記述していく箇所は、独特の書き方だなと思いますし、相応のスキルが必要と感じます。

今回は、mocktailの日本語記事をあまり見かけなかったので、半ば備忘録的なTips集を残しておきます。

主環境

  • riverpod: ^2.1.1
  • mocktail: ^0.3.0

mockitoとの違い

mocktailとmockitoとの違いについて触れておきます。

Flutter Testにおけるモックを作成するためのmockitoライブラリは、専用のアノテーションをつけて、build runnerにてmock用のコードを生成する必要があります。

mocktailライブラリだと、コード生成不要で簡単にモックが作成できるので、僕はこちらを使用しています。

https://pub.dev/packages/mocktail

※mockitoを使ったことがないので、それ以外の違いについては詳しく知りません。

mocktail Tips集

引数まで厳密に評価する必要がないときは、any()

  • Mock化したクラスのメソッドの引数は気にせず、単に実行したいだけであったり、戻り値を得たい場合はany()を使うと良い。
when(() => mockUserRepository.save(
      userModel: any(named: 'userModel'),
    )).thenAnswer((_) async => Future.value());

  • プリミティブ型でなければ、registerFallbackValueを使って引数を登録する必要がある。
registerFallbackValue(
  UserModel(
    id: 'koara',
    name: 'koara',
    gender: Gender.man,
    updatedAt: DateTime.now(),
    createdAt: DateTime.now(),
  ),
);

ウェイト用にawait Future<void>.value()を活用する

(テスト対象とすべきかはさておき)例として、以下のBluetooth ON/OFFを検出するStreamProviderを考えます。


// Bluetooth ON/OFFチェックを実施するStreamProvider
final bluetoothStateStreamProvider =
    StreamProvider.autoDispose<BluetoothState>((ref) {
  final adapter = ref.watch(beaconAdapterProvider);

  return adapter.listeningBluetoothState();
});

AsyncLoading<BluetoothState>状態を確認後、await Future<void>.value();にて、bluetoothStateStreamProviderのStateがAsyncDataになるのを待つことができます。


      // The first read if the loading state
      expect(
        container.read(bluetoothStateStreamProvider),
        const AsyncLoading<BluetoothState>(),
      );

      // ウェイト-AsyncDataになるのを待つ
      await Future<void>.value();

      verify(() => mockBeaconAdapter.listeningBluetoothState());
      expect(
        container.read(bluetoothStateStreamProvider),
        const AsyncData<BluetoothState>(BluetoothState.stateOn),
      );

オブジェクトの一部を評価したいときは、isA<T>().having()

isAは、一部のフィールド値が期待値通りかを確認したいときに便利。

      expect(container.read(beaconListStreamProvider).value, [
        isA<Beacon>()
            .having((beacon) => beacon.proximityUUID, 'proximityUUID',
                dummyBeacons.first.proximityUUID)
            .having((beacon) => beacon.major, 'major', dummyBeacons.first.major)
            .having(
                (beacon) => beacon.minor, 'minor', dummyBeacons.first.minor),
      ]);

stateが保持できないときは、container.listen()を使って監視しておく

参考記事
https://zenn.dev/omtians9425/articles/4a74f982788bdb

AsyncValueを持つStateNotifierクラスのUnitテスト

参考記事
https://codewithandrea.com/articles/unit-test-async-notifier-riverpod/

AsyncValueを持つStateNotifierクラスは、非同期処理にてLoading/data/errorのいずれかの状態に遷移させたりするかと思います。

Mockを継承したListenerクラスを用意して、非同期処理による状態の移り変わりをテストします。

// a generic Listener class, used to keep track of when a provider notifies its listeners
class Listener<T> extends Mock {
  void call(T? previous, T next);
}

使い方は、下記のような形で、前述のcontainer.listen()の引数listnerとして設定。

// create a listener
final listener = Listener<EditChildState>();
// listenで状態遷移を監視
container.listen(
  editChildStateProvider,
  listener,
  fireImmediately: true,
);

listnerは、Mockを継承しているので、verify()による検証が可能になっています。

verify(
  () =>
      listener(null, EditChildState(value: const AsyncData<void>(null))),
);

下記コードは、個人開発アプリ「ヨンデ」のお子さま情報を登録する処理のテスト部分です。

最後のverifyInOrderでEditChildStateが持つAsyncValueの状態遷移を確認しています。

また、上記参考記事でanyを使って厳密な期待値の定義を回避する方法も紹介されていたので、それを使用しています。

// create a listener
final listener = Listener<EditChildState>();
// listenで状態遷移を監視
container.listen(
  editChildStateProvider,
  listener,
  fireImmediately: true,
);

verify(
  () =>
      listener(null, EditChildState(value: const AsyncData<void>(null))),
);

await container.read(editChildStateProvider.notifier).submit(
      testChildren[0].nickname,
      Gender.girl,
      DateTime(2020, 3, 5),
      null,
    );

// 状態の移り変わりを順に確認
verifyInOrder([
  // data → loading
  () => listener(
        EditChildState(value: const AsyncData<void>(null)),
        any(
            that: isA<EditChildState>().having((state) => state.value,
                'value', const AsyncLoading<void>())),
      ),
  // loading → data
  () => listener(
        any(
            that: isA<EditChildState>().having((state) => state.value,
                'value', const AsyncLoading<void>())),
        any(
          that: isA<EditChildState>().having(
              (value) => value.value.error, 'error', '登録済みのニックネームです。'),
        ),
      ),
]);

Discussion