Flutter(Dart)のテスト技法

Flutterのアプリ開発では、アーキテクチャとしてMVVMが採用されるケースがしばしばあります。
その中でデータ層やビジネスロジックの実装として以下の技術を利用されている。
- StateNotifier
- Stream
- Future

これらのテスト技法についてまとめました。

# MVVMについて
アプリの扱うデータを持つためのローカル・リモートのデータソースがあり、そのローカル・リモートを抽象化するためのリポジトリまでをデータ層と呼びます。UIの状態ホルダーとなるViewModelがリポジトリを介してデータを取得し、そのデータを整形しUI層に公開します。


![dfceced36738-20230902.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/251754/ac5db872-5077-e893-fa11-ccb73c16e63e.png)

出典：https://developer.android.com/jetpack/guide?hl=ja

# 非同期処理をテストする
APIリクエストやshared preference等、リモート・ローカルのデータソースからデータを取得するような、非同期で行われる処理についてテストを書いていきます。

## 準備
[fake_async](https://pub.dev/packages/fake_async)をインストールしてください。
```sh
flutter pub add --dev fake_async
```

## delayを入れて偽装したテスト
APIを通してリモートのデータソースから取得するテストを考えていきます。データソースでAPIリクエストする処理はテスト時は偽装し、delayを入れてテストすることがしばしばあるかと思います。
fake_asyncを使うとテストの現実の実行時間は変えずに、偽装的な時間でdelayを入れることができます。

次のメソッド`fetchData`は排他処理がされている。ここで排他処理についてのテストを書いていきます。
```dart
class FooRepository {
....
  FooRepository({required DataSource dataSource})
      : _dataSource = dataSource;
  final DataSource _dataSource;

  // 排他処理用のフラグ
  bool _isFetching = false;
  
  // このメソッドのテストを書いていきます
  Future<void> fetchData(final String url) async {
    if (!_isFetching) {
      _isFetching = true;
      _dataSource.fetchData(url);
      _isFetching = false;
    }
  }
}


// データソースは次のようにdelayで偽装
class SpyRemoteDetaSource implements DataSource {
....

  // fetchData()の呼び出し回数を記録
  int callCount = 0;

  Future<void> fetchData(final String url) async {
    callCount++;
    // データ取得処理をdelayで偽装
    await Future.delayed(const Duration(milliseconds: 500));
  }
}

```


テストコードを見ていきます。
```dart
test("fetchDataの排他制御", () {
  // テストのブロックをfakeAsyncで囲みます (1)
  fakeAsync((fakeTime) {
    final spyDataSource = SpyRemoteDetaSource();
    final repository = FooRepository(dataSource: spyDataSource);
    repository.fetchData("url");
    repository.fetchData("url");
    expect(spyDataSource.callCount, 1);
    
    // 偽装時間を進めます (2)
    fakeTime.elapse(const Duration(milliseconds: 500));
    repository.fetchData("url");
    expect(spyDataSource.callCount, 2);
  });
});
```

1. テストのブロックをfakeAsyncで囲みます。
2. FakeAsync.elapse()で偽装時間を進めます。

# Streamをテストする
次のようにStreamを使って上位の層にデータを公開し、上位層でlistenする実装パターンについてのテストを考えていきます。

```dart
// データを流し込むストリーム
final _settingStreamController = StreamController<String>.broadcast();

// 外部に公開する
Stream getSettingStream() {
  return _settingStreamController.stream;
}

// 設定を保存する
void saveSetting(final String newValue) {
  _settingStreamController.sink.add(newValue);
}
```


テストコードを見ていきます。
```dart
test("streamに更新後の値が流れてくる", () async {
  final repository = SettingsRepository();
  // Streamに流れてくる順に値をmatcherに指定する
  expectLater(repository.getSettingStream(), emitsInOrder(["設定１", "設定２"]));
  await repository.saveSetting("設定１");
  await repository.saveSetting("設定２");
});
```

matcherとしてemitsInOrderを使い、Streamに流れてくる値を順に列挙します。

# StateNotifierをテストする
Riverpodを採用しているプロジェクトでは、StateNotifierをViewModelとして利用していることがあります。ここではStateNotifierのテスト方法を記載します。

データ読み込み時にローディングインジケーターを表示するような処理にてロード中の状態を更新します。
このような処理のテストコードを見ていきます。

```dart
test("ロード中の状態更新", () {
  final viewModel = HomeViewModel();
  viewModel.load();
  // debugStateにアクセスし状態を参照する
  expect(viewModel.debugState, const HomeState(isLoading: true));
});
```

# まとめ
- 非同期処理をdelayで偽装するときは、fake_asyncを使用することで偽装的な時間を遅延し、現実の実行時間には影響を与えずにテストできます。
- Streamのテストは、expectLaterのmatcherにemitsInOrderに流れてくる値を順番に指定します。
- StateNotifierのテストは、debugStateで状態を参照します。