Flutter Webの単体テストはdart:htmlライブラリを含まないように作る

[Flutter Web](https://flutter.dev/web)で動画プレイヤーアプリを作成したのですが、単体テストに `dart:html` ライブラリを含むと不都合があり、工夫が必要な場面があったので記事にしました。

# dart:htmlライブラリを含むと単体テストが不便

## アプリの概要

スプラトゥーン2のプレイヤー向けのシーン自動頭出し機能付きの動画プレイヤーアプリです。自動検出された特定シーンの時刻ボタンを押すと、その場所に飛びます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/f74e4029-ecfc-d3eb-cbaf-ac24dd346142.png" width="800">

※ 左上の動画は任天堂株式会社のスプラトゥーン2からの引用です。
※ 開発中の画面です。

使ってみたい方はこちら

https://ikut.app/

## 単体テストの例

秒数をコロン区切りの時刻テキストに変換するutil系メソッドの単体テストがあります。
例: 192.0 → 3:12

### 実装

```util.dart
import 'package:sprintf/sprintf.dart';
/// 秒数を時間テキストにする
String toTimeString(double seconds) {
  int hour = seconds.toInt() ~/ 3600;
  int minute = (seconds.toInt() - hour * 3600) ~/ 60;
  int second = seconds.toInt() % 60;
  if (hour >= 1) {
    return sprintf("%d:%02d:%02d", [hour, minute, second]);
  } else {
    return sprintf("%d:%02d", [minute, second]);
  }
}
```

### 単体テスト

```util_test.dart
void main() {
  test('test toTimeString', () async {
    expect(toTimeString(0.0), '0:00');
    expect(toTimeString(59.9), '0:59');
    expect(toTimeString(60.0), '1:00');
    expect(toTimeString(9 * 60 + 59.9), '9:59');
    expect(toTimeString(10 * 60.0), '10:00');
    expect(toTimeString(59 * 60.0 + 59.9), '59:59');
    expect(toTimeString(3600.0), '1:00:00');
    expect(toTimeString(3600 + 59 * 60.0 + 59.9), '1:59:59');
    expect(toTimeString(7200.0), '2:00:00');
  });
}

```

## コマンドラインでの単体テストの実行

これだけならば、このコマンドで単体テストを実行することができます。

```bash
flutter test
```

しかし、実は同じファイルにHTMLのvideo要素を作成、取得するメソッドが同居していました。なので、`dart:html`ライブラリがインポートされています。

```util.dart
import 'dart:html';
// ↑ これが問題

import 'package:sprintf/sprintf.dart';

String toTimeString(double seconds) {
  int hour = seconds.toInt() ~/ 3600;
  int minute = (seconds.toInt() - hour * 3600) ~/ 60;
  int second = seconds.toInt() % 60;
  if (hour >= 1) {
    return sprintf("%d:%02d:%02d", [hour, minute, second]);
  } else {
    return sprintf("%d:%02d", [minute, second]);
  }
}

/// １つしか無いvideo要素
VideoElement _ikutVideoElement;

/// 1つしかないvideo要素を取得する
VideoElement getVideoElement(BuildContext context) {
  if (_ikutVideoElement == null) {
    final videoElement = VideoElement();
    videoElement.controls = true;
    // リスナ設定などがあるが省略
    _ikutVideoElement = videoElement;
  }
  return _ikutVideoElement;
}
```

その場合は `flutter test` コマンドでテスト実行すると　`Not found: 'dart:html'` エラーになってしまいます。

```
lib/util/util.dart:1:8: Error: Not found: 'dart:html'
import 'dart:html';
```

しかし `--platform chrome` 引数を付けるとエラーになりません。

```bash
flutter test --platform chrome
```

`dart:html` ライブラリの呼び出しにはChromeが必要なようです。

実行速度はChromeの起動時間が必要なので遅くなります。
`flutter test` で4秒の単体テスト実行が `flutter test --platform chrome` では30秒かかります。


### GitHub Actionsでの動作

ちなみにGitHub ActionsでもChromeを使う単体テストは動作します。

```.github/workflows/check.yml
name: check
on:
  push:
    branches:
      - main
  pull_request:
    types:
      - opened
      - synchronize
      - reopened
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: asdf-vm/actions/install@v1
      - run: flutter pub get
      - run: flutter pub run build_runner build
      - run: flutter test --platform chrome
```

## Android Studioで単体テストを実行する

開発していると1ファイルや1テストメソッドだけの単体テストを実行したいことは多いと思います。

Android Studioでは左側の　`Run test` ボタンから単体テスト実行できます。

![スクリーンショット 2021-11-10 0.38.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/8c8fc97c-efa5-8805-f06b-954fec7fe1b8.png)

そのケースでもテストコードやテスト対象のコードに `dart:html` ライブラリを含んでいるとエラーになります。

Run/Debug Configurationsで `--platform chrome` を設定することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/1428a901-2511-6f74-2ed5-6d48733d09f5.png)

しかしこの場合はChromeが開き「Flutter Test Browser Host」が出たまま止まってしまいました。

![スクリーンショット 2021-11-10 2.21.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/ee9e241f-cc21-8e03-4aa7-a9d50fcaf0f0.png)

# 単体テストはdart:htmlライブラリを含まないようにして作る

前項の結果から単体テストは `dart:html` ライブラリを含まないようにして作ったほうが取り回しが良さそうです。
`dart:html` ライブラリを含むメソッド等は別ファイルに移動することにしました。

- toTimeStringメソッド(　`dart:html`　不使用、テスト対象)は `util.dart` ファイルに設置。
- getVideoElementメソッド(　`dart:html`　使用、テスト対象ではない)は `web_util.dart` ファイルに設置。

# 別のdart:htmlライブラリを含むProviderを使っているStateNotifierProviderの単体テストを作成する

このケースの場合は[Conditionally Importing](https://dart.dev/guides/libraries/create-library-packages#conditionally-importing-and-exporting-library-files)を使う必要があったので紹介します。

## 該当部分の仕様

該当アプリには設定画面があり、頭出しされたシーンの再生は何秒前から開始するかや、スキップするときの秒数をスライダーを左右にドラッグすることで設定出来ます。設定はブラウザローカルに保存されて、次回同じブラウザでアプリを開いたときは復帰されます。

![スクリーンショット 2021-11-12 0.29.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/79422d50-9480-6670-7190-1e90f72aa4fd.png)

※　開発中の画面です。

## 単体テスト指針

- 設定画面を構築するためのStateNotifierをテストする。
- StateNotifierは設定をブラウザローカルに保存する担当オブジェクトを持っている。それをモック化して呼び出され方を確認する。

## アプリの実装

設定のモデルはこのようになっています。([freezed](https://pub.dev/packages/freezed)を使用)

```ikut_config.dart
@freezed
abstract class IkutConfig with _$IkutConfig {
  /// 設定情報
  /// [skipTime] スキップで進んだり戻ったりする秒数
  /// [deathBeforeTime] やられたシーンはN秒前から見たい
  /// [killBeforeTime] たおしたシーンはN秒前から見たい
  /// [endBeforeTime] 試合終了へのスキップはN秒前から見る
  /// [slowRatio] スロー再生のレシオ(分母)
  /// [fastRatio] 早送り再生のレシオ(分子)
  /// [extractDeathScene]　やられたシーンを頭出しする
  /// [extractKillScene]　たおしたシーンを頭出しする
  factory IkutConfig(
      int skipTime,
      int deathBeforeTime,
      int killBeforeTime,
      int endBeforeTime,
      int slowRatio,
      int fastRatio,
      bool extractDeathScene,
      bool extractKillScene) = _IkutConfig;
}
```

(endBeforeTimeフィールドとfastRatioフィールドは固定値を入力して使っています。)

それを使ってこのようにWidgetを構築しています。横にスライドして値を変更する部品には[Slider](https://api.flutter.dev/flutter/material/Slider-class.html)を使用しています。
Widget内で使用しているStateNotifierProviderであるikutConfigStateNotifierProviderの実装は次で説明します。

```config.dart
class ConfigContentWidget extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final config = ref.watch(ikutConfigStateNotifierProvider);
    final nameTextStyle = TextStyle(fontSize: 14, color: IkutColors.blackText);
    final sliderWidth = 400.0;

    return Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 進むときと戻るときの秒数
          SizedBox(height: 16),
          Text(sprintf(Strings.configSkipMinutes, [config.skipTime]),
              style: nameTextStyle),
          Container(
            width: sliderWidth,
            child: Slider(
                activeColor: Colors.pinkAccent.shade400,
                inactiveColor: Colors.pinkAccent.shade100,
                value: config.skipTime.toDouble(),
                min: 1.0,
                max: 10.0,
                divisions: 9,
                label: config.skipTime.toString() + Strings.seconds,
                onChanged: (value) {
                  final configStateNotifier =
                      ref.read(ikutConfigStateNotifierProvider.notifier);
                  configStateNotifier.setSkipTime(value.toInt());
                }),
          ),
          // やられたシーンは何秒前からみるか
          SizedBox(height: 16),
          Text(sprintf(Strings.configDeathBeforeTime, [config.deathBeforeTime]),
              style: nameTextStyle),
          Container(
            width: sliderWidth,
            child: Slider(
                activeColor: Colors.pinkAccent.shade400,
                inactiveColor: Colors.pinkAccent.shade100,
                value: config.deathBeforeTime.toDouble(),
                min: 3.0,
                max: 10.0,
                divisions: 7,
                label: config.deathBeforeTime.toString() + Strings.seconds,
                onChanged: (value) {
                  final configStateNotifier =
                      ref.read(ikutConfigStateNotifierProvider.notifier);
                  configStateNotifier.setDeathBeforeTime(value.toInt());
                }),
          ),
          // 残りの設定項目は省略
        ]);
  }
}

```

StateNotifierと、そのProviderはこのようになっています。
LocalStorageDataStoreが設定値のブラウザローカルへの保存と読込を担当しますが、次で説明します。

```ikut_confing_state_notifier_provider.dart
class IkutConfigStateNotifier extends StateNotifier<IkutConfig> {
  IkutConfigStateNotifier(
      this._dataStore, IkutConfig config)
      : super(config);

  IkutConfigStateNotifier.override(IkutConfig ikutConfig) : super(ikutConfig);

  /// Window.localStorageに設定値を保存する担当オブジェクト
  late LocalStorageDataStore _dataStore;

  /// 進むときと戻るときの秒数を設定する
  void setSkipTime(int skipTime) {
    state = state.copyWith(skipTime: skipTime);
    _dataStore.setInt(Strings.configNameSkipTime, skipTime);
  }

  /// やられたシーンは何秒前から見るを設定する
  void setDeathBeforeTime(int deathBeforeTime) {
    state = state.copyWith(deathBeforeTime: deathBeforeTime);
    _dataStore.setInt(Strings.configNameDeathBeforeTime, deathBeforeTime);
  }

  // あとの設定項目は省略する
}

// ignore: top_level_function_literal_block
final ikutConfigStateNotifierProvider =
    StateNotifierProvider<IkutConfigStateNotifier, IkutConfig>((context) {
  final dataStore = context.read(localStorageDataStoreProvider);
  final skipTime = dataStore.getInt(Strings.configNameSkipTime, 5);
  final deathBeforeTime =
      dataStore.getInt(Strings.configNameDeathBeforeTime, 4);
  final killBeforeTime = dataStore.getInt(Strings.configNameKillBeforeTime, 4);
  final slowRatio = dataStore.getInt(Strings.configNameSlowRatio, 2);
  final extractDeathScene =
      dataStore.getInt(Strings.configExtractDeathScene, 1) != 0;
  final extractKillScene =
      dataStore.getInt(Strings.configExtractKillScene, 1) != 0;
  return IkutConfigStateNotifier(
      dataStore,
      IkutConfig(skipTime, deathBeforeTime, killBeforeTime, 3, slowRatio, 2,
          extractDeathScene, extractKillScene));
});
```

設定値は[Window.localStorage](https://developer.mozilla.org/ja/docs/Web/API/Window/localStorage)を使い、ブラウザローカルに保存しています。しかしそのためには問題の `dart:html` ライブラリのインポートが必要です。よって、以下の3つをファイルを分けて作成します。

- インターフェース
- なにもしない実装
- 本番実装

インターフェースです。

```local_storage_data_store_provider.dart
/// Window.localStorageからデータを出し入れする機能のインターフェース
abstract class LocalStorageDataStore {
  /// int型の値を取得する
  /// [name] 保存キー
  /// [defaultValue] 未設定の時の返却値
  int getInt(String name, int defaultValue);

  /// 設定を保存する
  /// [name] 設定名
  /// [intValue] 設定値
  void setInt(String name, int intValue);
}
```

なにもしない実装です。

```local_storage_data_store_fake.dart
class LocalStorageDataStoreImpl implements LocalStorageDataStore {
  @override
  int getInt(String name, int defaultValue) {
    return defaultValue;
  }

  @override
  void setInt(String name, int intValue) {}
}

```

こちらが `dart:html` ライブラリを使う、本番で使われる実装です。

```local_storage_data_store_impl.dart
import 'dart:html';

import 'package:ikut/localDataStore/local_storage_data_store.dart';

/// Window.localStorageからデータを出し入れする機能の本番実装
class LocalStorageDataStoreImpl implements LocalStorageDataStore {
  /// int型の値を取得する
  /// [name] 保存キー
  /// [defaultValue] 未設定の時の返却値
  @override
  int getInt(String name, int defaultValue) {
    String value = window.localStorage[name];
    if (value == null) {
      return defaultValue;
    } else {
      return int.parse(value);
    }
  }

  /// 設定を保存する
  /// [name] 設定名
  /// [intValue] 設定値
  @override
  void setInt(String name, int intValue) {
    window.localStorage[name] = intValue.toString();
  }
}
```

実装の取得のために、RiverpodのProviderはこのように書きました。

```local_storage_data_store_provider.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'local_storage_data_store.dart';
import 'local_storage_data_store_fake.dart'
    if (dart.library.html) 'local_storage_data_store_real.dart';

final localStorageDataStoreProvider =
// ignore: unnecessary_cast
    Provider((_) => LocalStorageDataStoreImpl() as LocalStorageDataStore);
```

重要な箇所は import 文の `if (dart.library.html)` の部分です。 `dart:html` ライブラリがあれば、 `local_storage_data_store_real.dart` が取り込まれ、無ければ `local_storage_data_store_fake.dart` が取り込まれます。Webアプリ実行時は `dart:html` ライブラリがあるので `local_storage_data_store_real.dart` が取り込まれ、単体テスト実行時は `dart:html` ライブラリが無いので `local_storage_data_store_fake.dart` が取り込まれます。


## 単体テストの実装

単体テストはこのようになります。モックオブジェクトの作成のために、[mockito](https://pub.dev/packages/mockito)を使用しました。

```ikut_config_state_notifier_provider_test.dart
@GenerateMocks([LocalStorageDataStore, IkutConfigRepository])
void main() {
  // Window.localStorageへの保存をモック化したオブジェクト
  final dataStore = MockLocalStorageDataStore();

  test('test ikutConfigStateNotifier', () async {
    // 呼ばれるメソッドの実装を定義する
    when(dataStore.getInt(Strings.configNameSkipTime, 5)).thenReturn(5);
    when(dataStore.getInt(Strings.configNameDeathBeforeTime, 4)).thenReturn(4);
    when(dataStore.getInt(Strings.configNameKillBeforeTime, 4)).thenReturn(4);
    when(dataStore.getInt(Strings.configNameSlowRatio, 2)).thenReturn(2);
    when(dataStore.getInt(Strings.configExtractDeathScene, 1)).thenReturn(1);
    when(dataStore.getInt(Strings.configExtractKillScene, 1)).thenReturn(1);

    // LocalStorageDataStoreの実装をモックに差し替える
    final container = ProviderContainer(overrides: [
      localStorageDataStoreProvider.overrideWithValue(dataStore)
    ]);

    // StateNotifierを取得する
    final configStateNotifier =
        container.read(ikutConfigStateNotifierProvider.notifier);
    // 更新された状態を取得するラムダ
    final getState = () => container.read(ikutConfigStateNotifierProvider);
    // StateNotifierの初期値を確認
    expect(getState(), IkutConfig(5, 4, 4, 3, 2, 2, true, true));
    // skipTimeを5秒から3秒に変更
    configStateNotifier.setSkipTime(3);
    // dataStoreのメソッドが意図通り呼ばれたか確認する
    verify(dataStore.setInt(Strings.configNameSkipTime, 3));
    // StateNotifierの状態更新を確認する
    expect(getState(), IkutConfig(3, 4, 4, 3, 2, 2, true, true));
    // deathBeforeTimeを4秒から8秒に変更
    configStateNotifier.setDeathBeforeTime(8);
    // dataStoreのメソッドが意図通り呼ばれたか確認する
    verify(dataStore.setInt(Strings.configNameDeathBeforeTime, 8));
    // StateNotifierの状態更新を確認する
    expect(getState(), IkutConfig(3, 8, 4, 3, 2, 2, true, true));
    // 以下省略
  });
}

```

# まとめ

`dart:html` ライブラリを含んだメソッドは単体テストの実行にChromeが必要で取り回しが悪いですが、単体テスト用の実装と本番実装を同じクラス名で作り、[Conditionally Importing](https://dart.dev/guides/libraries/create-library-packages#conditionally-importing-and-exporting-library-files)で取り込むファイルを切り替えることで、取り回しの良い単体テストを作ることができました。