Flutter Webで単体テストしてますか?

Flutter Webで単体テストしてますか?
こんにちは。Woven Payment Solution開発グループの大杉です。
私たちのチームでは、 Woven by Toyota において Toyota Woven City で使われる決済システムの開発を行っており、普段はKotlin/Ktorでバックエンド開発とFlutterによるWeb/モバイルのフロントエンド開発をしています。
Flutter Webでは、Web固有のパッケージを使用しているとテスト実行でエラーになってしまうことがあります。
そのため、今回の記事ではFlutter Webのコードをテスタブルな状態に維持するために工夫していることを、特に単体テストにフォーカスしてまとめたいと思います。
なお、これまでのフロントエンド開発ストーリーについては過去の記事を参照していただけると幸いです。
Flutter Webとは
初めに、Googleによって開発が進められているクロスプラットフォーム開発のフレームワークであるFlutterの内、Webアプリ開発に特化したフレームワークのことです。
Flutterの開発言語であるDartは、ソースコードを事前にJavaScriptに変換し、HTML、Canvas、CSSを使用して描画処理を行うことができるので、モバイルアプリで開発したコードをそのままWebアプリに移植することができます。
Flutter Webの実装方法
基本的な実装は、モバイルアプリ開発と同じ方法で実装できます。
一方で、DOM操作やブラウザAPIにアクセスしたい場合はどうすればいいでしょうか?
これもDartの組み込みパッケージでdart:htmlなどのWebプラットフォーム向けのパッケージが用意されています[1]。例えば、ファイルダウンロード機能もJSによる一般的なWebアプリ開発と同じように実装することができます。
下記のWidgetは、カウントアップした数値をテキストファイルとしてダウンロードするという何に使うかわからない機能を持ったサンプルアプリです。Floating Buttonをクリックするとテキストファイルのダウンロードが行われます。
import 'dart:html';
import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;
  
  State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            IconButton(
              onPressed: _incrementCounter,
              icon: const Icon(Icons.add),
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          AnchorElement(href: 'data:text/plain;charset=utf-8,$_counter')
            ..setAttribute('download', 'counter.txt')
            ..click();
        },
        tooltip: 'Download',
        child: const Icon(Icons.download),
      ),
    );
  }
}
Flutter Webのコードを単体テストする
先ほどのサンプルコードのテストコードを以下のように用意しました(ほとんどflutter createして出力されたときのままです)。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sample_web/main.dart';
void main() {
  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
    await tester.pumpWidget(const MyApp());
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();
    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);
  });
}
次のテストコマンドを実行するかVS CodeのTestingタブから上記のテストコードを実行してみましょう。
$ flutter test
このままテストを実行すると、次のようなエラーが出るかと思います。
Error: Dart library 'dart:html' is not available on this platform.
// 省略
lib/utils/src/html_util.dart:4:3: Error: Method not found: 'AnchorElement'.
  AnchorElement(href: 'data:text/plain;charset=utf-8,$data')
どうやらdart:htmlのインポートに何か問題があるようです。
プラットフォームごとのDartコンパイラ
公式ドキュメントを確認すると、Dartコンパイラの実行には、
- JITコンパイラを行うDart VMとマシンコードを生成するAOTコンパイラのNativeプラットフォーム
 - DartコードをJSにトランスパイルするWebプラットフォーム
 
の2つがあることがわかります。また、それぞれのプラットフォームで利用できるパッケージが一部異なるようです。
| プラットフォーム | 利用できるパッケージ | 
|---|---|
| Native | dart:ffi, dart:io, dart:isolate | 
| Web | dart:html, dart:js, dart:js_interopなど | 
つまり、前述のテストはVM上で実行されていたため、dart:htmlを利用できなかったということがわかりました。
Webプラットフォームパッケージのインポートエラーを回避する方法として、テスト実行時にプラットフォームを指定する方法があります。
下記のオプションをつけてコマンド実行することで、テストをChrome上で(つまり、Webとして)実行することを指定できます[2]。
$ flutter test --platform chrome
Flutter WebのテストコードはChromeで実行すべきか?
ブラウザAPIの利用はWebアプリを開発する上で避けられないことだと思いますが、Flutter WebのテストコードはChrome上で実行すべきなのでしょうか?
個人的な意見ですが、なるべくChromeを使わない方が良いというのが私の考えです。
理由としては、
- テスト実行の際にバックグラウンドでChromeを起動する必要があるため、テストの起動時間が増大してしまう
 - CI環境にChromeをインストールする必要があり、CI環境のコンテナサイズが大きくなる。または、コンテナのセットアップに時間がかかってしまう
 
ことが想像でき、CI環境の金銭的コストがかなり増えてしまうことが考えられます(もちろん、ローカルでささっと確認する程度や富豪な方でしたら問題ないです)。
実際に、オプションでプラットフォームを指定しない標準ケース(Native)とChromeを指定したケース(Web)を比較したローカル環境の実行結果を載せました。
| プラットフォーム | プログラム実行時間(秒) | トータルテスト実行時間(秒) | 
|---|---|---|
| Native | 2.0 | 2.5 | 
| Web | 2.5 | 9.0 | 
上記表から、Webの方は実際にテストの起動に大幅に時間がかかるようになりました。さらに、テスト実行時間も25%ほど増大していることもわかるかと思います。

Webプラットフォーム依存のコードは分離しよう
Webプラットフォームを指定しないで前述のエラーを回避するにはどうしたら良いでしょうか?
実は、Dartにはパッケージを条件でインポート・エクスポートする方法と、プラットフォームがWebかNativeかを判定するためのフラグも用意されています[3]。
| フラグ | 説明 | 
|---|---|
| dart.library.html | Webプラットフォームかどうか | 
| dart.library.io | Nativeプラットフォームかどうか | 
これらを駆使することでエラーを回避することができます。
まずは、以下のようにしてWeb用・Native用のダウンロード機能モジュールを用意し、前述のWebパッケージ使用箇所をテスト対象のコードから分離しましょう。
import 'dart:html';
void download(String fileName, String data) {
  AnchorElement(href: 'data:text/plain;charset=utf-8,$data')
    ..setAttribute('download', fileName)
    ..click();
}
void download(String fileName, String data) =>
    throw UnsupportedError('Not support this platform');
そして、上記モジュールのインポートをプラットフォームごとに切り替える方法は以下のようになります。
import 'package:flutter/material.dart';
- import 'dart:html'
+ import './utils/util_io.dart'
+     if (dart.library.html) './utils/util_html.dart';
class MyHomePage extends StatefulWidget {
  // 省略
}
class _MyHomePageState extends State<MyHomePage> {
  // 省略
  
  Widget build(BuildContext context) {
    return Scaffold(
      
      // 省略
      floatingActionButton: FloatingActionButton(
        onPressed: () {
-          AnchorElement(href: 'data:text/plain;charset=utf-8,$_counter')
-            ..setAttribute('download', 'counter.txt')
-            ..click();          
+          download('counter.txt', _counter.toString());
        },
        tooltip: 'Download',
        child: const Icon(Icons.download),
      ),
    );
  }
}
エクスポートをする場合は、別途util.dartなどの仲介ファイルを用意してWidget側からインポートすることになると思います(ここでは省略します)。
export './utils/util_io.dart' 
    if (dart.library.html) './utils/util_html.dart';
以上で、Web依存のコードによるエラーを回避してNativeプラットフォーム上でテストを実行することができるようになりました。
Webプラットフォーム依存の外部パッケージにはNativeプラットフォーム用のスタブも作ろう
私たちのシステムは認証基盤にKeycloakを採用しています。
Flutter Webアプリ上でKeycloakの認証をするために以下のパッケージを使用しています。
リンクを開いてもらえるとわかると思いますが、このパッケージはWebのみをサポートしています。
このパッケージのおかげで楽に認証処理を実装することができたのですが、認証モジュールという特性上そのインターフェースは色々なところで利用されるため、APIコールなど認証情報を必要とするようなWidgetがすべてWebプラットフォーム依存となってしまい、CIでテストできなくなる状況になってしまったことがありました(この間はローカルで--platform chromeオプションでテストして全PassしたらOKという性善説運用をしていました)。
ちなみに、このパッケージをインポートすると以下のエラーがテスト実行時に発生します。
Error: Dart library 'dart:js_util' is not available on this platform.
そこで、前述のインポート分離と同様な処置を外部パッケージにも行っていくのですが、ここではエクスポートを使ったパターンで実践していきたいと思います。手順は以下となります。
1. 仲介パッケージの作成
ここでは例としてinter_libというパッケージをサンプルコードのパッケージ内に作成しています。
flutter create inter_lib --template=package
実際のプロダクトコードでは、外部パッケージに準じたコードをプロダクトコード内に混入させないため、プロダクトとは別のパッケージを作成して外部パッケージを仲介させています。Melosを使うと簡単にマルチパッケージ開発ができるのでおすすめです。
2. Nativeプラットフォーム用のスタブの作成
keycloak_flutterのスタブを作るため、Githubのリポジトリを参照してインターフェースを模擬します(ライセンスの確認は適宜お願いします)。
プロダクトコード上で使用しているクラスやメソッドはすべてが必要になります。
作成したファイルは以下のようになっています。
srcディレクトリ以下のstub_のプレフィックスがついているものが外部パッケージのインターフェースを模擬したものです。
inter_lib
├── lib
│   ├── keycloak.dart
│   └── src
│       ├── stub_keycloak.dart
│       ├── stub_keycloak_flutter.dart
│       └── entry_point.dart
また、entry_point.dart は実際の外部パッケージと同じものをエクスポートするように定義しました(実際にはプロダクトコード内で使用しているインターフェースだけで十分です)。
export './stub_keycloak.dart'
    show
        KeycloakConfig,
        KeycloakInitOptions,
        KeycloakLogoutOptions,
        KeycloakLoginOptions,
        KeycloakProfile;
export './stub_keycloak_flutter.dart';
このinter_libをパッケージとして内部公開するため、以下のようにエクスポートの設定をします。
library inter_lib;
export './src/entry_point.dart'
    if (dart.library.html) 'package:keycloak_flutter/keycloak_flutter.dart';
3. 仲介パッケージをpubspec.yamlのdependenciesへ追加
pubspec.yamlにinter_libへの相対パスを追加します。
// 省略
dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
+  inter_lib:
+    path: './inter_lib'
// 省略
そして、元々外部パッケージを参照していたところをinter_libに置き換えます。
- import 'package:keycloak_flutter/keycloak_flutter.dart';
+ import 'package:inter_lib/keycloak.dart';
import 'package:flutter/material.dart';
import 'package:sample_web/my_home_page.dart';
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final keycloakService = KeycloakService(
    KeycloakConfig(
      url: 'XXXXXXXXXXXXXXXXXXXXXX',
      realm: 'XXXXXXXXXXXXXXXXXXXXXX',
      clientId: 'XXXXXXXXXXXXXXXXXXXXXX',
    ),
  );
  await keycloakService.init(
    initOptions: KeycloakInitOptions(
      onLoad: 'login-required',
      enableLogging: true,
      checkLoginIframe: false,
    ),
  );
  runApp(
    const MyApp(),
  );
}
以上、Webプラットフォーム依存パッケージのNativeプラットフォーム用スタブの作成フローでした。これでテストをVM上で実行できるようになります。
この方法は今回の例として用いたkeycloak_flutter以外にももちろん適用できます。

まとめ
今回の記事では、Flutter Webのコードをテスタブルな状態に維持するために単体テストで工夫していることをまとめました。
- Dartの実行環境には、WebプラットフォームとNativeプラットフォームがある
 flutter testはNativeプラットフォーム実行であり、dart:htmlなどのWebプラットフォーム用パッケージを使っているとエラーとなってしまうdart.library.io,dart/library/htmlのフラグを活用して、プラットフォームごとに実パッケージとスタブを切り替える実装をすると幸せになれる
関連記事 | Related Posts

Flutter Webで単体テストしてますか?

A Kotlin Engineer’s Journey Building a Web Application with Flutter in Just One Month

KotlinエンジニアがFlutterに入門して1ヶ月でWebアプリケーションを作った話

The Best Practices Found by Backend Engineers While Developing Multiple Flutter Applications at Once

Flutter Development Efficiency: A Step-by-Step Guide to Automating Web Previews Using GitHub Actions and Firebase Hosting
Integration of Flutter Apps with Native Features – Our Approach to Adding an Android-Specific Camera Analysis Library
We are hiring!
【プロジェクトマネージャー(iOS/Android/Flutter)】モバイルアプリ開発G/東京
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら、品質の高いモバイルアプリを開発し、サービスの発展に貢献することを目標としています。
【UI/UXエンジニア(フロントエンド)】DX開発G/大阪
DX開発グループについて全国約4,000店舗のトヨタ販売店の営業プロセスを中心に、販売店スタッフのお困りごとをテクノロジーとクリエイティブの力で解決に導く事業を展開しています。募集背景当グループでは、全国約4,000店舗のトヨタ販売店の営業プロセスを中心に、販売店スタッフのお困りごとをテクノロジーとクリエイティブの力で解決に導く「販売店DX事業」を展開しています。
