KINTO Tech Blog
Development

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

Cover Image for 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をクリックするとテキストファイルのダウンロードが行われます。

my_home_page.dart
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して出力されたときのままです)。

widget_test.dart

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%ほど増大していることもわかるかと思います。

tester

Webプラットフォーム依存のコードは分離しよう

Webプラットフォームを指定しないで前述のエラーを回避するにはどうしたら良いでしょうか?

実は、Dartにはパッケージを条件でインポート・エクスポートする方法と、プラットフォームがWebかNativeかを判定するためのフラグも用意されています[3]

フラグ 説明
dart.library.html Webプラットフォームかどうか
dart.library.io Nativeプラットフォームかどうか

これらを駆使することでエラーを回避することができます。

まずは、以下のようにしてWeb用・Native用のダウンロード機能モジュールを用意し、前述のWebパッケージ使用箇所をテスト対象のコードから分離しましょう。

util_html.dart
import 'dart:html';

void download(String fileName, String data) {
  AnchorElement(href: 'data:text/plain;charset=utf-8,$data')
    ..setAttribute('download', fileName)
    ..click();
}
util_io.dart
void download(String fileName, String data) =>
    throw UnsupportedError('Not support this platform');

そして、上記モジュールのインポートをプラットフォームごとに切り替える方法は以下のようになります。

my_home_page.dart
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側からインポートすることになると思います(ここでは省略します)。

util.dart
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 は実際の外部パッケージと同じものをエクスポートするように定義しました(実際にはプロダクトコード内で使用しているインターフェースだけで十分です)。

stub_keycloak_interface.dart

export './stub_keycloak.dart'
    show
        KeycloakConfig,
        KeycloakInitOptions,
        KeycloakLogoutOptions,
        KeycloakLoginOptions,
        KeycloakProfile;
export './stub_keycloak_flutter.dart';

このinter_libをパッケージとして内部公開するため、以下のようにエクスポートの設定をします。

keycloak.dart

library inter_lib;

export './src/entry_point.dart'
    if (dart.library.html) 'package:keycloak_flutter/keycloak_flutter.dart';

3. 仲介パッケージをpubspec.yamldependenciesへ追加

pubspec.yamlinter_libへの相対パスを追加します。

pubspec.yaml

// 省略

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
+  inter_lib:
+    path: './inter_lib'

// 省略

そして、元々外部パッケージを参照していたところをinter_libに置き換えます。

main.dart
- 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以外にももちろん適用できます。

successful people

まとめ

今回の記事では、Flutter Webのコードをテスタブルな状態に維持するために単体テストで工夫していることをまとめました。

  • Dartの実行環境には、WebプラットフォームとNativeプラットフォームがある
  • flutter testはNativeプラットフォーム実行であり、dart:htmlなどのWebプラットフォーム用パッケージを使っているとエラーとなってしまう
  • dart.library.io, dart/library/htmlのフラグを活用して、プラットフォームごとに実パッケージとスタブを切り替える実装をすると幸せになれる
脚注
  1. https://dart.dev/web ↩︎

  2. https://docs.flutter.dev/platform-integration/web/building#add-web-support-to-an-existing-app ↩︎

  3. https://dart.dev/guides/libraries/create-packages#conditionally-importing-and-exporting-library-files ↩︎

Facebook

関連記事 | Related Posts

We are hiring!

【Toyota Woven City決済プラットフォームフロントエンドエンジニア(Web/Mobile)】/Toyota Woven City Payment Solution開発G/東京

Toyota Woven City Payment Solution開発グループについて我々のグループはトヨタグループが取り組むToyota Woven Cityプロジェクトの一部として、街の中で利用される決済システムの構築を行います。Toyota Woven Cityは未来の生活を実験するためのテストコースとしての街です。

【Woven City決済プラットフォーム構築 PoC担当バックエンドエンジニア(シニアクラス)】/Toyota Woven City Payment Solution開発G/東京

Toyota Woven City Payment Solution開発グループについて私たちのグループはトヨタグループが取り組むWoven Cityプロジェクトの一部として、街の中で利用される決済システムの構築を行います。Woven Cityは未来の生活を実験するためのテストコースとしての街です。