6
2

はじめに

私の現場ではRiverpodを使った状態管理とDIで、レイヤードアーキテクチャ(またはリバーポッドアーキテクチャ)を採用しています。

メンバーにタスクが振られ、実装が完了したらテストコードも合わせて書いてプルリクエストを提出しています。

私は実務未経験で今回の現場に配属されましたが、そんな実務の経験を経てテストコードを書く技術も蓄積することができました。

この記事ではその中から、ユニットテスト(unit test)に焦点を当ててお話ししていきます。
題材としてはdata層に作成したshared_prefernsesのrepositoryをテストしていきます。

shared_prefernsesはローカルデータベースにキーとバリューの組み合わせでデータを保存する仕組みを提供するパッケージです。

記事の対象者

  • Riverpodを交えたテストコードを学びたい方
  • レイヤードアーキテクチャなど抽象化さて書かれたコードのテストについて学びたい方
  • shared_prefernsesのテストについて学びたい方

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.90.1)

サンプルプロジェクト

shared_preferencesを使ってローカルに4つの値を保存するアプリです。

  • アイコンの設定:bool
  • 背景色の番号:int
  • タイトルの文字:String
  • JSONで複数の設定:CustomSetting/文字列からJSONに変換

また、赤文字のボタンをタップしてデータを初期化(リセット)できます。

【Flutter】riverpodを使ったDIとレイヤードアーキテクチャ、特にデータ層について(仮).gif

ソースコード

実装内容について

今回は実装内容についての紹介ではなく、あくまでテストに関する内容のため詳細の説明は割愛します。
適時必要な部分は抜粋していきますが、不足する部分があるかもしれません。
ソースコードで確認していただくか、以下の記事でご確認ください。

ユニットテストの基礎について

今回はユニットテストの基本的な関数やテストの実行方法を理解している前提でお話しします。
もしもその部分がまだ不安な方は以前書いた記事がありますので、ぜひそちらをご覧ください。

1. フォルダとファイルの作成

こちらは【Flutter】ユニットテスト基本のき 2-1. フォルダとファイルの作成の転載になります。

テストのファイルはtestフォルダー内に作成します。
原則はテスト対象のファイルが置かれているディレクトリ構成に則ってディレクトリとテストファイルを作成します。
そのほうが探す際にわかりやすいからです。

今回で行けば以下のrepository.dartとprovider.dartのテストを書きたいです。


lib
├── data
│   ├── local_sources
│   │   ├── shared_preference.dart
│   │   └── shared_preference.g.dart
│   └── repositories
│       └── key_value_repository
│           ├── provider.dart // <= 💡 ここをテストしたい
│           ├── provider.g.dart
│           └── repository.dart // <= 💡 ここをテストしたい

その場合は以下のようにディレクトリを作り、ファイルを作成します。


test
├── data
│   └── repositories
│       └── key_value_repository
│           └── repository_test.dart // <= 😀

スクショで見るとこのような形になります。

スクリーンショット 2024-06-22 18.13.56.png

テストファイルは名称の末尾を必ず_test.dartとしないとテスト用のファイルだと認識されずにテストが実行できません。
ファイル作成後は必ず確認しましょう!

ファイルを手動で作っていってもいいですが、私は必要なディレクトリごとコピーして、
テストファイルに貼り付けます。
あとはファイルの名前の末尾に**_test**をつけて、ファイルの中身を一度全て削除してしまえば
そっくりそのままディレクトリ構造も作れてお手軽なのでおすすめです。

2. repository.dartをテストする

2-1. 実行環境の準備

まず初めにファイルの中身を準備していきます。
【Flutter】ユニットテスト基本のき 2-2. 実行環境で紹介したテンプレートをコピーして貼り付けます。
その上で今回のテストしたいグループの数、テストケースの数に合うように修正します。

エディタを分割して、左か右側に実装したコードを載せておくと内容を確認しながら書けるのでおすすめです。
今回の実装はlib/data/repositories/key_value_repository/repository.dartです。

スクリーンショット 2024-06-23 21.57.24.png

左がテスト、右が実装です。
テストはすでに書いてある時のスクショのため、コードブロックを折りたたんで撮影しています。

以下はテストコード内容です。

repository.dart

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

void main() {
  late ProviderContainer container;

  setUp(() {
    container = ProviderContainer();
  });
  tearDown(() {
    container.dispose();
  });
  group('key_value_repository test', () {
    test('値の変更を監視することができる', () {});
    test('アイコンの設定を取得し、保存できる', () {});
    test('背景色の番号を取得し、保存できる', () {});
    test('タイトルのテキストを取得し、保存できる', () {});
    test('カスタム設定を取得し、保存できる', () {});
    test('全てのデータを初期化できる', () {});
  });
}

グループに関してはエラーハンドリングや初期データを分けたテストなどはしていないため、1つにまとめています。

テストしたい内容は本来であればそのタスクの仕様に合わせて設定します。
つまり必ずしもメソッド単位というわけではありません。
今回は厳密に仕様を決めていないこともあるので、test()のコメントにある通り保存と取得関連は対象のデータごとにそれぞれまとめ、合計6個のテストケースに分かれています。

2-2. 定数&変数、setUp、tearDownの設定

repository.dart

void main() {
  /// KeyValueRepositoryBaseを使ってKeyValueRepositoryの機能にアクセスする
  late KeyValueRepositoryBase keyValueRepository;

  /// Riverpodを使用しているので、ProviderContainerを経由する
  late ProviderContainer container;

  setUp(() async {
    // 最初にmockの初期値を設定
    // これを最初に実行しないとローカルデータベースに影響が出る
    // 設定しておきたい初期値があるならここで設定する
    SharedPreferences.setMockInitialValues({
      // 例
      // KeyValueRepository.iconSettingKey : true,
    });
    container = ProviderContainer();
    keyValueRepository = container.read(keyValueRepositoryProvider);
  });
  tearDown(() {
    container.dispose();
  });

  // 省略

定数&変数

  • late KeyValueRepositoryBase keyValueRepository;
    今回は実装クラスの抽象化を行なっているので、呼び出しはインターフェースであるKeyValueRepositoryBaseとなります。

  • late ProviderContainer container;
    依存性の注入を行うのにRiverpodを仕様しています。KeyValueRepositoryBaseへアクセスする際にcontainerを経由するために使用します。

ProviderContainerについて
実は今回でいえばoverridesで上書きすることはないので、本来は
final container = ProviderContainer();で宣言しても良いです。
ただ大体の場合はsetUp()関数内でoverridesすることが多いため
そのままにしています。
ここは慣れてきたらしっかりと使い分けてもいいかもしれません。

setUp

今回設定するのは3点です。

  • SharedPreferences.setMockInitialValues({})
    主にエミュレータなどのデバイスデータに影響を与えないためテスト環境上にmockの保存先を作成します。
    今回は初期値なしの状態でテストします。
  • container = ProviderContainer();
    先ほど定数で宣言したcontainerを設定。今回は特にないのでProviderContainer()を代入するのみです。
  • keyValueRepository = container.read(keyValueRepositoryProvider);
    実装を見てみるとわかるのですが、KeyValueRepositoryBasekeyValueRepositoryProvider経由でアクセスできます。
    container経由でインスタンスを取得しています。
    そしてkeyValueRepositoryProviderが最終的に返却するのは
    実際の実装を持っているKeyValueRepository(ref);です。
    ここら辺が少し複雑ですが、頑張って整理していきましょう。
lib/data/repositories/key_value_repository/provider.dart

/// `KeyValueRepositoryBase` のインスタンスを生成
@Riverpod(keepAlive: true)
KeyValueRepositoryBase keyValueRepository(KeyValueRepositoryRef ref) {
  return KeyValueRepository(ref);
}

データベースのmock化
SharedPreferencesに限らずデータベース関連のパッケージであればテスト用のmock化が容易されていると思います。
まずは公式ドキュメントを確認しましょう。

tearDown()

container.dispose();のみです。
テスト用に作成したProviderContainerを破棄してリソースを解放します。
毎回破棄することでインスタンスの重複を防止して不要なメモリの消費を防ぎます。

これで基本の準備、設定は完了です。次からテストを書いていきます。

2-3. onValueChangeのテスト

以下に関連する実装部分を抜粋しています。

lib/data/repositories/key_value_repository/repository.dart

  /// アイコン設定のキー
  static const iconSettingKey = 'iconSetting';

  /// 設定値の変更をアプリケーション全体にブロードキャストするための`StreamController`
  final _onValueChanged = StreamController<String>.broadcast();

  @override
  Stream<String> get onValueChange => _onValueChanged.stream;

  @override
  Future<void> setIconSetting({bool? value}) => _set(iconSettingKey, value);

  Future<void> _set(String key, Object? value) async {
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (value) {
      case final int intValue:
        await pref.setInt(key, intValue);
      case final double doubleValue:
        await pref.setDouble(key, doubleValue);
      case final bool boolValue:
        await pref.setBool(key, boolValue);
      case final String stringValue:
        await pref.setString(key, stringValue);
      case final DateTime dateTimeValue:
        await pref.setString(key, dateTimeValue.toIso8601String());
      case final List<String> listStringValue:
        await pref.setStringList(key, listStringValue);
      case null:
        await pref.remove(key);
      case _:
        await pref.setString(key, jsonEncode(value));
    }

    // keyという文字列をストリームに流す
    _onValueChanged.add(key);
  }

テストしたものが以下です。

test/data/repositories/key_value_repository/repository_test.dart

test('値の変更を監視することができる', () async {
      // ストリームの値が流れてきたら格納する変数を定義
      final histories = <String>[];

      // onValueChangeの購読を宣言し、ストリームが流れてきたらhistoriesに追加する
      keyValueRepository.onValueChange.listen(histories.add);

      // setIconSettingメソッドを実行する
      await keyValueRepository.setIconSetting(value: true);

      // 結果が反映されるまで少し待機
      await Future<void>.delayed(Duration.zero);

      // 格納されている値は'iconSetting'である
      expect(histories, [KeyValueRepository.iconSettingKey]);
    });
    

テスト内でFuture型のメソッドを扱うため、test()asyncを付与しています。

onValueChangeStringの値を受け取るStreamです。
そこでStreamの受け取り先をhistoriesというリストの変数で受け取ります。

onValueChangeで購読を宣言した後にsetIconSettingメソッドを実行しています。
setIconSettingメソッドの内部実装は_set()メソッドで、その実行の最後に
_onValueChanged.add(key);で値をストリームに流しています。
よってここで'iconSetting'というキーがhistoriesに格納されます。

すぐにexpectで検証に入ってしまうと、setIconSettingメソッドの実行は終わっているのですが、ストリームに値がまだ流れておらず結果が[ ]となって失敗してしまいます。
そこでawait Future<void>.delayed(Duration.zero);で少し待機する必要があります。

最後にexpect()で値が流れてきているかの検証を行なって完了です。

2-4. 取得と保存のテスト

今回は4種類のデータの保存と取得メソッドがありますが、どれもほとんど同じですのでここでは「アイコンの設定を取得し、保存できる」、のテストについて解説します。

以下、実装の抜粋です。

lib/data/repositories/key_value_repository/repository.dart

  @override
  Future<bool?> getIconSetting() => _get(iconSettingKey);

  @override
  Future<void> setIconSetting({bool? value}) => _set(iconSettingKey, value);

以下がテスト内容です。

test/data/repositories/key_value_repository/repository_test.dart

    test('アイコンの設定を取得し、保存できる', () async {
      // アイコンの設定を取得
      var iconSetting = await keyValueRepository.getIconSetting();

      // 初期値はnullである
      expect(iconSetting, isNull);

      // アイコンの設定をtrueで保存
      await keyValueRepository.setIconSetting(value: true);

      // アイコンの設定を取得
      iconSetting = await keyValueRepository.getIconSetting();

      // アイコンの設定はtrueである
      expect(iconSetting, true);
    });
    

onValueChangeの時と同様にtest()asyncを付与しています。

まずは変数にgetIconSetting()メソッドを実行して値を取得します。

初期値がnullであることを確認します。
今回の実装では最初に初期値がなかった場合は初期値を設定するのではなくnullのままにしています。

次にアイコンの設定を保存します。
アイコンの設定を再び取得して変わっていることを確認します。

このことからアイコンの設定を取得し、また保存できることを確認することができました。

2-5. データの初期化のテスト

いかが実装部分です。

lib/data/repositories/key_value_repository/repository.dart

  @override
  Future<void> initData() async {
    final pref = await ref.read(sharedPreferencesProvider.future);
    await pref.clear();

    // カスケード記法で重複する`_onValueChanged`を一つに省略している
    _onValueChanged
      ..add(iconSettingKey)
      ..add(backgroundColorNumberKey)
      ..add(titleTextKey)
      ..add(customSettingKey);
  }

以下がテスト内容です。

test/data/repositories/key_value_repository/repository_test.dart

    test('全てのデータを初期化できる', () async {
      // アイコンの設定をtrueで保存
      await keyValueRepository.setIconSetting(value: true);

      // 背景色の番号を1で保存
      await keyValueRepository.setBackgroundColorNumber(1);

      // タイトルのテキストを'iOS'で保存
      await keyValueRepository.setTitleText('iOS');

      // 保存するカスタム設定を定義
      const saveCustomSetting = CustomSetting(
        iconSetting: true,
        backgroundColorNumber: 1,
        titleText: 'iOS',
      );

      // カスタム設定を保存
      await keyValueRepository.setCustomSetting(saveCustomSetting);

      // アイコンの設定を取得
      var iconSetting = await keyValueRepository.getIconSetting();

      // 背景色の番号を取得
      var backgroundColorNumber =
          await keyValueRepository.getBackgroundColorNumber();

      // タイトルのテキストを取得
      var tileText = await keyValueRepository.getTileText();

      // カスタム設定を取得
      var customSetting = await keyValueRepository.getCustomSetting();

      // アイコンの設定はtrueである
      expect(iconSetting, true);
      // 背景色の番号は1である
      expect(backgroundColorNumber, 1);
      // タイトルのテキストは'iOS'である
      expect(tileText, 'iOS');
      // カスタム設定はsaveCustomSettingと同じである
      expect(customSetting, saveCustomSetting);

      // 全てのデータを初期化
      await keyValueRepository.initData();

      // アイコンの設定を取得
      iconSetting = await keyValueRepository.getIconSetting();

      // 背景色の番号を取得
      backgroundColorNumber =
          await keyValueRepository.getBackgroundColorNumber();

      // タイトルのテキストを取得
      tileText = await keyValueRepository.getTileText();

      // カスタム設定を取得
      customSetting = await keyValueRepository.getCustomSetting();

      // アイコンの設定はnullである
      expect(iconSetting, isNull);
      // 背景色の番号はnullである
      expect(backgroundColorNumber, isNull);
      // タイトルのテキストはnullである
      expect(tileText, isNull);
      // カスタム設定はnullと同じである
      expect(customSetting, isNull);
    });
    

内容は今まで一番長いですが、実は保存と取得する項目が増えただけです。
まず最初に保存系4つのメソッド(setXxxx())を実行して値を保存し、
保存されていることを確認します。
その後、await keyValueRepository.initData();を実行して、データをすべて消します。
最後にすべての保存されている項目の値を取得して、すべてがnullであることを確認できれば完了です。

3. プロバイダをテストする

次にプロバイダーをテストしてみます。
ファイル作成の準備については割愛します。

今回はshared_preferencesに4つの設定を保存しています。
その4つの設定の状態をプロバイダーにして保存しています。
プロバイダーはWidgetTestをする際に必然的にテストすることが多いです。
そのため単体でテストすることは少ないですが、今回はあえてやってみようと思います。

今回は4つのプロバイダーのうち、アイコンの設定を管理するiconSettingProviderについてのテスト方法について解説していきます。

3-1. mockitoの導入

プロバイダーのテストを行うときに注意したいのがこのテストはあくまでプロバイダーのテストであって、
shared_preferencesやkey_value_repositoryのテストではないということです。
どういうことかというと、実装をまずはみてみます。

lib/data/repositories/key_value_repository/provider.dart

/// アイコン設定の値を提供するStreamを生成
/// `IconSettingRef`を通じてリポジトリにアクセスし、現在のアイコン設定を取得し、
/// その後、アイコン設定が変更されるたびに新しい値を提供する
@riverpod
Stream<bool?> iconSetting(IconSettingRef ref) async* {
  // キー値リポジトリのプロバイダーからリポジトリオブジェクトを取得
  final repository = ref.read(keyValueRepositoryProvider);

  // 最初のアイコン設定の値を取得し、yieldを使用してStreamに出力
  yield await repository.getIconSetting();

  // リポジトリの値変更通知を購読し、アイコン設定キーの変更のみにフィルターをかける
  await for (final _ in repository.onValueChange
      .where((key) => key == KeyValueRepository.iconSettingKey)) {
    // アイコン設定キーが変更されたとき、新しいアイコン設定の値を取得し、yieldでStreamに出力
    yield await repository.getIconSetting();
  }
}

上記の実装の中でkeyValueRepositoryProviderを使ってKeyValueRepositoryにアクセスしてメソッドを呼び出しています。
ただ、この中でたとえばgetIconSetting()が正しく機能しているかはテストの必要がありません。
なぜなら今回のテストしたい内容は以下だからです。

iconSettingProviderは設定の変更をストリームで配信することができる

今回のように他のクラスの機能が絡んでくる場合にはそのクラスをモックします。
モックする、とはあるクラスの機能を模倣することです。
たとえば上記のgetIconSetting()メソッであればこのメソッドを実行したら指定したアイコンの設定を返すように模倣します。
こうすることでKeyValueRepositoryの実装に依存せず、iconSettingProviderのテストを行うことができます。

そして、そのモックを簡単に生成してくれるのがmockitoというパッケージです。

尚、mockitoについてより深く知りたい方は以下の記事をご覧ください。

1. mockitoをインストール


flutter pub add mockito

まずは上記のコマンドを実行してプロジェクトにmockitoをインストールしましょう。

2. モック対象を指定して自動生成

test/data/repositories/key_value_repository/provider_test.dart

/// モッククラスを生成するアノテーション
@GenerateNiceMocks([
  MockSpec<KeyValueRepositoryBase>(),
])

次にテストファイルの最初に上記のように書きます。
アノテーションで@GenerateNiceMocksとし、モックしたいクラスを配列で渡します。
今回は一つだけですが、カンマ区切りで複数渡すことができます。
この時、モックするクラスはMockSpec型で渡します。
そして型アノテーションでは今回は依存性の注入を行なっているので、KeyValueRepositoryBaseを指定します。

準備ができたらいつもの自動生成コマンを実行します。


flutter pub run build_runner build --delete-conflicting-outputs

するとprovider_test.mocks.dartというファイルが生成されます。

スクリーンショット 2024-07-07 14.46.58.png

3-2. 定数&変数、setUp、tearDownの設定

test/data/repositories/key_value_repository/provider_test.dart

/// モッククラスを生成するアノテーション
@GenerateNiceMocks([
  MockSpec<KeyValueRepositoryBase>(),
])

/// テストはメイン関数の中で行う
void main() {
  // lateを使うことでここで宣言した値に各テストトランザクションからアクセスできる

  /// Riverpodを使用しているので、ProviderContainerを経由する
  late ProviderContainer container;

  /// mockしたMockKeyValueRepositoryBaseを使って機能にアクセスする
  late MockKeyValueRepositoryBase keyValueRepository;

  /// keyValueRepositoryのonValueChangeをモックするためのStreamController
  late StreamController<String> onValueChangeController;

  group('key_value_repository_provider test', () {
    /// テスト前の設定をここで行う
    setUp(() {
      // モックリポジトリのインスタンスを作成
      keyValueRepository = MockKeyValueRepositoryBase();
      // ストリームコントローラーを作成
      onValueChangeController = StreamController<String>();
      // プロバイダーのコンテナを作成し、リポジトリをオーバーライド
      container = ProviderContainer(
        overrides: [
          keyValueRepositoryProvider.overrideWithValue(keyValueRepository),
        ],
      );

      // keyValueRepositoryのonValueChangeはStreamだが、
      // そのStreamをテスト内のonValueChangeControllerに差し替えている
      // これにより、onValueChangeのイベントをテスト内で制御できるようにする
      when(keyValueRepository.onValueChange)
          .thenAnswer((_) => onValueChangeController.stream);
    });

    /// テスト後のクリーンアップを行う
    tearDown(() async {
      container.dispose();
      await onValueChangeController.close();
    });

    // 省略
  

repositoryのところと共通部分はありますが、差異がある点を説明します。

変数&定数

  1. late MockKeyValueRepositoryBase keyValueRepository;
    3-1で生成したmockクラスを変数で宣言します。

  2. late StreamController<String> onValueChangeController;
    keyValueRepositoryonValueChangeはストリームです。そのストリームを模倣して流すためにコントローラーを用意します。

setUp

まずは前半部分です。

test/data/repositories/key_value_repository/provider_test.dart

    setUp(() {
      // モックリポジトリのインスタンスを作成
      keyValueRepository = MockKeyValueRepositoryBase();
      // ストリームコントローラーを作成
      onValueChangeController = StreamController<String>();
      // プロバイダーのコンテナを作成し、リポジトリをオーバーライド
      container = ProviderContainer(
        overrides: [
          keyValueRepositoryProvider.overrideWithValue(keyValueRepository),
        ],
      );
    

それぞれの変数にインスタンスを代入しています。
この時containerにはProviderContainerを代入するが、その際にoverridesで代入するプロバイダーを上書きします。
ここでkeyValueRepositoryProvider.overrideWithValueによって返却しているのがkeyValueRepositoryです。

つまり本来の実装ではKeyValueRepositoryが返されますが、ここではMockKeyValueRepositoryBase
を返すように差し替えています。

次に後半部分です。

test/data/repositories/key_value_repository/provider_test.dart

      // keyValueRepositoryのonValueChangeはStreamだが、
      // そのStreamをテスト内のonValueChangeControllerに差し替えている
      // これにより、onValueChangeのイベントをテスト内で制御できるようにする
      when(keyValueRepository.onValueChange)
          .thenAnswer((_) => onValueChangeController.stream);

when()メソッドはmockitoの機能で、モック対象のスタブを作成する機能です。
スタブとは、特定のメソッドが呼び出されたときに、特定の動作をさせるためのものです。

上記ではkeyValueRepository.onValueChangeが呼ばれたときに、onValueChangeController.streamを返すようにスタブが設定されています。
つまり実際のストリームではなく、テスト内で制御可能なonValueChangeController.streamが返されるようになります。

この方法により、テスト内でストリームのイベントを手動でトリガーして、特定のシナリオをシミュレートすることができます。

tearDown

test/data/repositories/key_value_repository/provider_test.dart

 /// テスト後のクリーンアップを行う
    tearDown(() async {
      container.dispose();
      await onValueChangeController.close();
    });
    

await onValueChangeController.close();でストリームを閉じています。
ここで閉じておかないと、新しいストリームがテストごとに追加されてしまい、テストの実行速度に影響してしまうからです。

3-3. iconSettingProviderのテスト

実装とテスト全体

まずはもう一度実装内容をおさらいしましょう。

lib/data/repositories/key_value_repository/provider.dart

@riverpod
Stream<bool?> iconSetting(IconSettingRef ref) async* {
  // キー値リポジトリのプロバイダーからリポジトリオブジェクトを取得
  final repository = ref.read(keyValueRepositoryProvider);

  // 最初のアイコン設定の値を取得し、yieldを使用してStreamに出力
  yield await repository.getIconSetting();

  // リポジトリの値変更通知を購読し、アイコン設定キーの変更のみにフィルターをかける
  await for (final _ in repository.onValueChange
      .where((key) => key == KeyValueRepository.iconSettingKey)) {
    // アイコン設定キーが変更されたとき、新しいアイコン設定の値を取得し、yieldでStreamに出力
    yield await repository.getIconSetting();
  }
}

以下にテストの全体を載せておきます。

test/data/repositories/key_value_repository/provider_test.dart

  test('iconSettingProviderは設定の変更をストリームで配信することができる', () async {
      // 初回のgetIconSettingをスタブ化
      when(keyValueRepository.getIconSetting())
          .thenAnswer((_) => Future.value(false));

      // 初期値を確認する
      var iconSetting = await container.read(iconSettingProvider.future);

      // 非同期の処理なので一旦待機させる
      await Future<void>.delayed(Duration.zero);

      // 左が検査対象、右が該当する結果
      expect(iconSetting, isFalse);

      // getIconSettingが1回呼ばれる
      verify(keyValueRepository.getIconSetting()).called(1);

      // trueに変更する
      // getIconSettingのスタブを上書きする
      when(keyValueRepository.getIconSetting())
          .thenAnswer((_) => Future.value(true));
      // ストリームを流す => await forの中の2回目のgetIconSettingが呼ばれる
      onValueChangeController.add(KeyValueRepository.iconSettingKey);

      // 現在のiconSettingの値を取り出す
      iconSetting = await container.read(iconSettingProvider.future);

      // 非同期の処理なので一旦待機させる
      await Future<void>.delayed(Duration.zero);

      // 設定が変わってtrueになる
      expect(iconSetting, isTrue);

      // getIconSettingが2回呼ばれる
      // 1回目が初期値を読み込むときで、onValueChangeを検知して2回目のget
      verify(keyValueRepository.getIconSetting()).called(2);

      // ここで合計何回呼ばれたかを確認したいのであればgetIconSettingに対する
      // verifyはここでのみ呼び出す今回でいうとgetIconSettingに対して、
      // すでにverifyを2回使っているので合計値を出すことはできない

      // verify(keyValueRepository.getIconSetting()).called(3); // <= ❌
    });

ここはものすごく混乱するところなので、エディタ左右のどちらかに実装内容を展開して確認しながらテストを書いていきましょう。

スクリーンショット 2024-07-07 15.43.56.png
左がテスト、右が実装です

初期値を取得しての検証

実装内容の処理順番と内容を見ながらスタブを組み合わせて検証していきます。

まず最初の検証を行います。
実装を見てみるとiconSettingProviderを購読すると、最初に初期値を取得して配信しています。

lib/data/repositories/key_value_repository/provider.dart
 yield await repository.getIconSetting();

なのでまずは最初のgetIconSettingを実行した値をwhen()でスタブ化します。
getIconSettingはFutrue型のメソッドで戻り値がboolです。
なので、返却する値を.thenAnswer()とい形で記述します。
コールバックで記述するため、初期値をfalseで設定した場合は(_) => Future.value(false)と記述します。

test/data/repositories/key_value_repository/provider_test.dart
      // 初回のgetIconSettingをスタブ化
      when(keyValueRepository.getIconSetting())
          .thenAnswer((_) => Future.value(false));

      // 初期値を確認する
      var iconSetting = await container.read(iconSettingProvider.future);

      // 非同期の処理なので一旦待機させる
      await Future<void>.delayed(Duration.zero);

      // 左が検査対象、右が該当する結果
      expect(iconSetting, isFalse);

      // getIconSettingが1回呼ばれる
      verify(keyValueRepository.getIconSetting()).called(1);

ここで初期値を確認する際にawait container.readとしています。
watchlistenにしてしまうと、その時の値ではなく変化した最後の値になってしまい検証がしづらいため
readにします。
iconSettingProvider.futureとすることでAsyncValueを剥がしてbool?の値を受け取ることができます。

そのあとすぐにexpectで検証したいところですが、Stream型は値が反映されるまでに時間を要するため、
await Future<void>.delayed(Duration.zero);で値が反映されるまで待機させます。

when()でスタブ化したメソッドは検証が可能です。
よく使う検証がverify().called()メソッドです。
指定したメソッドが何回呼ばれているか?を検証するものです。

設定が変更された場合を検証する

test/data/repositories/key_value_repository/provider_test.dart

      // trueに変更する
      // getIconSettingのスタブを上書きする
      when(keyValueRepository.getIconSetting())
          .thenAnswer((_) => Future.value(true));
          
      // ストリームを流す => await forの中の2回目のgetIconSettingが呼ばれる
      onValueChangeController.add(KeyValueRepository.iconSettingKey);

      // 現在のiconSettingの値を取り出す
      iconSetting = await container.read(iconSettingProvider.future);

      // 非同期の処理なので一旦待機させる
      await Future<void>.delayed(Duration.zero);

      // 設定が変わってtrueになる
      expect(iconSetting, isTrue);

      // getIconSettingが2回呼ばれる
      // 1回目が初期値を読み込むときで、onValueChangeを検知して2回目のget
      verify(keyValueRepository.getIconSetting()).called(2);

まずは前回同様getIconSettingメソッドのスタブを変更します。

そして、次がちょっと違うのですがonValueChangeController.add(KeyValueRepository.iconSettingKey);でストリームに値を流しています。
これは設定が変わる、ということはどこかでアイコン設定を保存するsetIconSettingメソッドが実行されて、その結果_onValueChanged.add(key);が実行されたことを意味します。
このkeyの部分はその設定のキーであり、今回でいうところのKeyValueRepository.iconSettingKeyです。
本来はsetIconSettingメソッドが実行されると流れるようになっているストリームを手動で実行しています。

そして現在のアイコン設定を取り出して検証しています。
最後のverify().called()が2になっているのに注意です。
実装をみると、プロバイダーへアクセスした初回にgetIconSettingを1回呼び出します。
次にonValueChangeを検知した際にはawait forで待機していた部分が実行され、2回目のgetIconSettingが実行されています。

verifyでの検証注意

コメントにも書いていますが、verifyは同一のメソッドに対して検証する場合はスタック性になっています。
このテスト内でgetIconSettingメソッドは合計で3回呼ばれています。
今回のテストではgetIconSettingのverifyを2回実行し、それぞれ1回呼びれていることと、2回呼ばれていることというふうに3回を分割して1、2で検証しています。
基本はそのメソッドが呼ばれたタイミングでverifyを使って検証するのでこれでいいのすが、
もしも仮にこのテスト内で呼ばれた総合の数を検証したい場合はverify(keyValueRepository.getIconSetting())をテストの最後にだけ使うようにしましょう。

4. テストカバレッジを出してみよう

最後にテストカバレッジを出してみましょう。
このプロジェクトはderryコマンドを使って一発でカバレッジを出力することができます。
コマンドは以下です。


derry cov

しかし、こちらは下準備も必要です。derryが実行できる環境を自身のPCに設定する必要があります。
詳しい手順は以下の記事をご参照ください。

全体のカバレッジ

スクリーンショット 2024-07-07 17.00.22.png

※ 全く書いていないものもまだあるので0%があります😰

今回の記事で書いたrepositoryとproviderのカバレッジ

スクリーンショット 2024-07-07 17.01.14.png

かなり全体を網羅したテストが書けたのではないでしょうか💪

カバレッジどこまで埋めるの問題

これはそのプロジェクトチームによるので、その方針に合わせれば良いでしょう。
まず大前提として実装した時の仕様に合わせてテストケースを書きます。
その結果、目標のカバレッジに届いていない場合はテストケースが足りていないということになります。
私の現場ではディレクトリ単位で見て50%を切らないようにするのを目標にしています。

終わりに

この記事では、Riverpodを使用した状態管理とDIの環境で、shared_preferencesを使ったリポジトリの
ユニットテストについて解説しました。

テストはコードの品質を保つために非常に重要です。
しかし、実務未経験であったり初学者にとっては非常に難しい内容だと思います。
今回の記事を通してユニットテストの学習のお役に立てれば幸いです。

何か疑問や質問があれば、ぜひお気軽にコメントをお寄せください。
最後までお読みいただき、ありがとうございました。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2