6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-02-28

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

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

アプリの概要

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

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

使ってみたい方はこちら

単体テストの例

秒数をコロン区切りの時刻テキストに変換する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');
  });
}

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

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

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]);
  }
}

/// 1つしか無い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 引数を付けるとエラーになりません。

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

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

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

image.png

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

スクリーンショット 2021-11-10 2.21.29.png

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

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

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

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

このケースの場合はConditionally Importingを使う必要があったので紹介します。

該当部分の仕様

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

スクリーンショット 2021-11-12 0.29.26.png

※ 開発中の画面です。

単体テスト指針

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

アプリの実装

設定のモデルはこのようになっています。(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を使用しています。
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を使い、ブラウザローカルに保存しています。しかしそのためには問題の 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を使用しました。

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で取り込むファイルを切り替えることで、取り回しの良い単体テストを作ることができました。

6
3
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
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?