16
6

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 5 years have passed since last update.

Flutterでインテグレーションテスト(デバイスを使うテスト)

Posted at

前回、iOS用のダイアログとAndroid用のダイアログを出し分けたので、それのテスト用にインテグレーションテストを書いてみました。

インテグレーションテストは、エミュレーターや実機を繋いで、自動でアプリを動かしながら行うテストです。UnitTestやWidgetTestだけでは拾いきれないシナリオテスト系、画面デザインの確認用、などに使います。

なお、Flutterのインテグレーションテストは、コマンドがdriveなので、driverテストとも言ったりします。

環境など

ツールなど バージョンなど
MacBook Air Early2015 macOS Mojave 10.14.5
Android Studio 3.6.1
Java 1.8.0_131
Flutter 1.12.13+hotfix.5
Dart 2.7.0
Xcode 10.2

基本的な手順

1.依存パッケージの追加

flutter_driverdev_dependencies:に追加します。

pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_driver:  # 追加
    sdk: flutter   # 追加

2.テストファイルを作成

(1)driveテスト専用フォルダを作成

プロジェクトルートの直下に、test_driverというフォルダを作成します。
libstestと同じ階層になります。

$ tree -L 1
.
├── README.md
├── android
├── assets
├── build
├── ios
├── lib
├── pubspec.lock
├── pubspec.yaml
├── screen_shots
├── test
└── test_driver

(2)テストケースファイルの作成

app.dartというファイルと、app_test.dartというファイルを先ほど作ったtest_driverフォルダ下に置きます。

ファイル名は任意ですが、xxx.dartと、xxx_test.dartというのはセットで作る必要があります。

$ tree test_driver/
test_driver/
├── app.dart
└── app_test.dart

3.テストアプリの起動コード

app.dartを次のようにします。

app.dart
import 'package:flutter_driver/driver_extension.dart';
import 'package:my.example.app/main.dart' as app;

void main() {
  enableFlutterDriverExtension();
  app.main();
}

このコードが、テストコマンドによって起動され、結果、Flutterアプリが起動します。
見て分かるとおり、Flutterアプリ側のmain関数を呼んでいるだけなので、実際のアプリがそのまま起動します。

4.テストコード

(1)基本的な書き方

テストコードは、app_test.dart側に書きます。

app_test.dart
void main() {
  group('App Driver Test', () {
    FlutterDriver driver;

    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    tearDownAll(() async {
      if (driver != null) {
        driver.close();
      }
    });

    Future<void> _takeScreenShot(String filename) async {
      await driver.waitUntilNoTransientCallbacks();
      final pixels = await driver.screenshot();

      final file = File('./test_driver/screenshots/$filename.png');
      await file.writeAsBytes(pixels);
      print('wrote $file');
    }

    test('起動画面', () async {
      final health = await driver.checkHealth();
      print(health.status);

      await _takeScreenShot('calendar');
    });
  });
}
  • setUpAllで、deviceに接続するのを待っています。これはすべてのテストが開始する前に1度だけ呼ばれます。
  • tearDownAllでは、deviceとの接続を切っています。これはすべてのテストが終わった最後の一度だけ実行されます。
  • _takeScreenShotはスクリーンショットを取る関数です。
  • driver.checkHealthは、毎回やった方が良いというようなことが書いてあるので、入れてあります。これをすると、enableFlutterDriverExtensionの記述漏れを発見できるそうです。

書き方は、UnitTestやWidgetテストとそんなに変わりないように見えますね。
が、注意しなくてはならないのは、テストコードは純粋なDart言語のみで書かれていなければならないということです。

Flutterのパッケージに依存したコードがあってはならないということです。
どういうことかというと、import文に、flutterが付くパッケージは入っていてはダメと言うことです。(flutter_driver.dartは除く)

  • package:flutter_test.dartなどが入っていてはダメ
  • package:flutter/material.dart等もダメ
  • 直接app_test.dartのimportにはなくても、他のimport先でそれらを読んでいてもダメ

特に3つ目は引っかかりやすいので、注意が必要です。私もここにしばらくハマっていました。
これらが含まれてしまっていると、以下のようなエラーが出て、driveテストが実行できません。(スプラッシュまでは起動するので、お、行ったかと思いますが、その後落ちます)

file:///Users/myuser/flutter/packages/flutter_test/lib/src/accessibility.dart:8:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui;
       ^
file:///Users/myuser/flutter/packages/flutter_test/lib/src/binding.dart:8:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui;
       ^
file:///Users/myuser/flutter/packages/flutter_test/lib/src/matchers.dart:8:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui;
       ^
file:///Users/myuser/flutter/packages/flutter_test/lib/src/matchers.dart:9:8: Error: Not found: 'dart:ui'
import 'dart:ui';
       ^
file:///Users/myuser/flutter/packages/flutter_test/lib/src/test_pointer.dart:12:1: Error: Not found: 'dart:ui'
export 'dart:ui' show Offset;
^
file:///Users/myuser/flutter/packages/flutter/lib/src/rendering/binding.dart:8:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui show window;
       ^
file:///Users/myuser/flutter/packages/flutter/lib/src/rendering/box.dart:6:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui show lerpDouble;
       ^
file:///Users/myuser/flutter/packages/flutter/lib/src/rendering/debug_overflow_indicator.dart:6:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui;
       ^
file:///Users/myuser/flutter/packages/flutter/lib/src/rendering/editable.dart:8:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui show TextBox;
       ^
file:///Users/myuser/flutter/packages/flutter/lib/src/rendering/error.dart:5:8: Error: Not found: 'dart:ui'
import 'dart:ui' as ui show Paragraph, ParagraphBuilder, ParagraphConstraints, ParagraphStyle, TextStyle;
       ^
Stopping application instance.
Driver tests failed: 254

(3)実行コマンド

$ flutter drive --target=test_driver/app.dart

--target以降のオプションは必要です。たとえテストファイルが1組しかなくても、必要です。

(4)ウィジェットをタップする方法

ウィジェットをタップする方法です。タップしたいウィジェットには、あらかじめkey属性をセットしておく必要があります(アプリ側のコードで)。

      final widget = find.byValueKey('10');
      await driver.waitFor(widget);
      await driver.tap(widget);

'10'というkeyが付けられたウィジェットが表示されるのを待って、タップしています。

(5)テキストの表示チェック

'counter'というkey属性を付けられたTextウィジェットがあるとします。
そのテキストが"0"であることをチェックする例です。

   final counterTextFinder = find.byValueKey('counter');
   expect(await driver.getText(counterTextFinder), "0");

ただ、細かい表示内容のチェックは、WidgetTestでする方が良いと思います。
というのも、findsOneとかfindsNothingとかはflutter_testパッケージのものなので、使うことが出来ません。

どうしても目視でしか確認できないようなことに使うべきだと思いますが、その場合にも、上記の制限のため、あまり思うようなチェックコードが書けないです。私の場合、スクリーンショットをひたすら撮るだけにして、CIからそれらの画像をArtifactsとしてダウンロードして結局人間の目で確認することにしました。

DIどうする

どうしてもデータ保存や通信処理などモックしたい場合があると思います。
たとえば、私は「強制アップデートダイアログ」の表示を、iOSとAndroidで変えたので、その表示の確認をこのdriveテストでやろうと思ってましたが、最新バージョンの取得はFirebaseのRemote Configを使っており、テストの時にいちいちRemote Configを書き変えるのは得策ではありません。
テストによって、自動的に返す値をモック化して渡したいものです。

そこでいろいろハマったのですが・・・

Flutter driveのテストは、アプリを起動した後、drive機構が接続するという性質上、起動時に走るVersionCheckServiceをMockitoのwhenを使ってテストメソッド毎に戻り値を差し替えるというのは実現は無理と判断しました。
また、1つのdriveテスト内ではアプリの状態が引き継がれるため(テスト毎にアプリの再起動が行われない)、バージョンチェックサービスの戻り値を変更してアプリを起動し直す、ということも出来ませんでした。

では、どうしたか?
テストファイルを2種類作りました。

  • update.dartupdate_test.dartの組み合わせ
    • VersionCheckServiceが常にtrueを返すテスト
  • app.dartapp_test.dartの組み合わせ
    • VersionCheckServiceが常にfalseを返すテスト

他のリポジトリクラスのモック化などは、共通して行いました。
以下、私のプロジェクトでとった手法のサンプルになります。これがベストプラクティスでは無いと思いますが、DIを使うときの1つの形にはなっていると思うので、参考になれば幸いです。

1. テスト用のアプリ起動コードを変更

(1)共通起動関数を作成

TestApp.dartというファイルをtest_driver下に作りました。
中身を次のようにします。

TestApp.dart
/// Mock RecordRepository
class MockRecordRepository extends Mock implements RecordRepository {}

/// MockAnalytics
class MockAnalyticsService extends Mock implements AnalyticsService {}

void setupLocator({VersionCheckService versionCheckService}) {
  locator.registerLazySingleton(() => NavigationService());
  locator.registerLazySingleton<AnalyticsService>(() => MockAnalyticsService());
  locator.registerLazySingleton<VersionCheckService>(() => versionCheckService);
}

void testMain(VersionCheckService versionCheckService) async {
  setupLocator(versionCheckService: versionCheckService);

  // 描画の開始
  runApp(Provider<RecordRepository>(
    create: (context) => MockRecordRepository(),
    dispose: (context, bloc) => bloc.dispose(),
    child: MyApp(),
  ));
}

locatorは、lib/main.dartで宣言しているget_itパッケージのオブジェクトです。

lib/main.dart
import 'package:get_it/get_it.dart';

GetIt locator = GetIt.instance;

get_itパッケージを使って、DIしているServiceクラスたちを、Mockitoを使ってモック化しています。
VersionCheckServiceだけは、テスト毎にモック化したオブジェクトを直接受け取るようにしています。これは、バージョンチェックの戻り値をそれぞれのテストで固定の値に書き変えるためです。

RecordRepositoryはProviderパターンですが、こちらもモック化オブジェクトでDIします。

(2) app.dartの変更

TestApp.dartで作ったtestMain関数を呼ぶようにします。

app.dart
import 'TestApp.dart';

/// VersionCheck
class MockVersionCheckService extends Mock implements VersionCheckService {
  @override
  Future<bool> versionCheck() async {
    return false;
  }
}

void main() {
  enableFlutterDriverExtension();
  testMain(MockVersionCheckService());
}

このテストは、バージョンチェックサービスは常にfalseつまりアップデート無しを返します。アップデートダイアログは表示されません。

(3)update.dartの作成

update.dart
/// VersionCheck
class MockVersionCheckService extends Mock implements VersionCheckService {
  @override
  Future<bool> versionCheck() async {
    return true;
  }
}

void main() {
  enableFlutterDriverExtension();
  testMain(MockVersionCheckService());
}

こちらは、バージョンチェックサービスが常にtrueつまりアップデート有りを返します。必ずアップデートダイアログが表示されます。

2.まとめて実行する

flutter driveコマンドには、--targetオプションが必須だと先ほど書きましたが、このターゲットを複数指定することが出来ません。
従って、ローカルで仮にapp.dartupdate.dartを両方続けてテストしたいときは、次のようにコマンドを結局2つ打たなければなりません。

$ flutter drive --target=test_driver/update.dart
$ flutter drive --target=test_driver/main.dart

面倒くさいですよね。ってことで、スクリプトにしちゃいます。

driver_test.sh
# !/bin/sh

rm ./test_driver/screenshots/*.png

flutter clean
flutter drive --target=test_driver/update.dart
flutter drive --target=test_driver/main.dart

ついでにスクリーンショットフォルダをクリアするのも入れました。

なお、エミュレーターやシミュレーターの起動、実機との接続などは、自己責任です。

3.Codemagic

CI/CDをCodemagicで回すようにしています

(1)driveテストの設定

driveテストを有効にしてみます。

codemagic_test_drive.png

update.dartのテストを実行するように指定しました。

[Save]をクリックします。

(2)Post-testのスクリプト

恐らく、driveテストをその前で指定しているので、iOSシミュレーターが起動しっぱなしのはずでは?だったら続けてflutter driveしたら他のテスト行けるんじゃないか?

と思って試したら、BINGOでした。

# !/bin/sh
flutter drive --target=test_driver/app.dart
cp  -r test_driver/screenshots $FCI_EXPORT_DIR/screenshots

app.dartのテスト実行と、screenshotsフォルダのアップロードを指定しています。

post_test_script.png

ただ、この方法は、今後Codemagicがシミュレーター/エミュレーターを閉じてしまうような仕様変更を行った場合、使えなくなります。。。

(4)実行後Artifactsからダウンロード

ワークフローのArtifactsにzipファイルが出来ています。

codemagic_artifacts.png

解凍すると、ちゃんとスクリーンショットが撮れています。

downloaded_artifact.png

driveテストはiOSシミュレーターかAndroidエミュレーターのどちらかしか、1つのワークフローでは実行できないので、両方やるにはワークフローを2つ作らねばなりませんが、私の場合は、もともとアンドロイダーなので、手元で開発する時はAndroidで、masterへのPR時に自動でiOSのスクリーンショットを撮って表示を一括確認する、といった用途で使っていこうかなと思っています。

デバッグ実行したい

コマンドラインからのテスト実行だと、ブレークポイントを貼ったり出来ずデバッグに苦労することもあるかも知れません。
そんなときに、AnroidStudioから起動できると便利ですよね。
探したら見つかったので、メモしておきます。

1.アプリ起動コンフィグの追加

  • [main.dart]となっている部分をクリックする
    • UnitTest等を実行後はテストファイル名に変わっている場合があります。

configuration_spinner.png

  • [Edit Configurations...]を選ぶ

edit_configurations.png

  • [main.dart]を選んで、コピーする

copy_main.png

  • 設定をする
    • Name: main.dart(test)など
    • Dart entry point: test_driver下のアプリ起動ファイル(_testが付かない方)
    • Additional arguments: --observatory-port 8888 --disable-service-auth-codesをセット

test_app_config.png

  • [OK]を押して保存

2.テスト用のコンフィグの追加

  • [Edit Configurations...]を開く
  • [+]アイコンをクリック

add_tool.png

  • Dart Command Line Appを選ぶ

dart_commandline_tool.png

  • 設定をする
    • Name: main_test.dartなど任意
    • Dart file: _test.dartファイルを選ぶ
    • Environment variables: VM_SERVICE_URL=http://127.0.0.1:8888/を入力

test_config.png

3.デバッグ実行

Andoridエミュレーターでやっていますが、iOSでも同じで出来るはずです。

(1)テストアプリ起動の設定を選び、デバッグボタンを押す

run_test_app.png

アプリの起動を待ちます。

(2)テストの設定を選び、デバッグボタンを押す

configurationsのスピナー(ドロップダウンリスト)から、_test.dart用の設定を選び、デバッグボタンを押します。

run_test.png

(3)ブレークポイント

テストコードの方にブレークポイントを貼って、止まっているところです。

breakpoints.png

感想

FlutterはWidgetTestがかなり手頃に書けるし実行も早いので、無理してdriveテストをたくさん書く必要は無いかと思います。
レイアウト崩れを検知できるようにスクリーンショットを全画面撮っていく、みたいな使い方が、私は一番よいように思いました。

また、AndroidとiOSどちらのdriveテストをやるかですが、AndroidはFirebase Test Labである程度出来るので、CIで回すのはiOSシミュレーターにするのが良いかなと思います。

CodemagicのサポートにSlackで「Flutter drive」複数指定できるようにならんの?」と聞いたら、「YAMLファイル自分で編集して」って言われちゃいました^^;

参考サイト

An introduction to integration testing
https://flutter.dev/docs/cookbook/testing/integration/introduction

Flutterのテストについて調べた
https://qiita.com/yujikawa/items/fe509b160df5ab9eb74e

Testing Flutter Apps on Real Android Devices with Flutter Driver
https://bitbar.com/blog/testing-flutter-apps-on-real-android-devices-with-flutter-driver/

[Flutter] Integration Test を書く時の 7 つのポイント
https://qiita.com/sensuikan1973/items/c8b56dfaf780e61af567

Hot Reload For Flutter Integration Tests
https://medium.com/flutter-community/hot-reload-for-flutter-integration-tests-e0478b63bd54

16
6
1

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?