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

[前回、iOS用のダイアログとAndroid用のダイアログを出し分けた](https://qiita.com/kasa_le/items/a7bae1b9aef6dfde2504)ので、それのテスト用にインテグレーションテストを書いてみました。

インテグレーションテストは、エミュレーターや実機を繋いで、自動でアプリを動かしながら行うテストです。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_driver`を`dev_dependencies:`に追加します。

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

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

### (1)driveテスト専用フォルダを作成
プロジェクトルートの直下に、`test_driver`というフォルダを作成します。
`libs`や`test`と同じ階層になります。

```console
$ 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`というのはセットで作る必要があります。

```console
$ 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テストが実行できません。(スプラッシュまでは起動するので、お、行ったかと思いますが、その後落ちます)

```console
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)実行コマンド

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

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


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

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

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

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

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

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

```dart
   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.dart`と`update_test.dart`の組み合わせ
  - `VersionCheckService`が常にtrueを返すテスト
- `app.dart`と`app_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.dart`と`update.dart`を両方続けてテストしたいときは、次のようにコマンドを結局2つ打たなければなりません。

```console
$ 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で回すようにしています](https://qiita.com/kasa_le/items/c8d45dcfaad6989009ac)。

### (1)driveテストの設定
driveテストを有効にしてみます。

![codemagic_test_drive.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/a85146fe-f906-b5a5-bee6-233d7cbaa747.png)

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

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

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

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

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

```sh
#!/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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/8367b641-4198-ed1e-dac6-d6a642c1f3cb.png)

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

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

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

![codemagic_artifacts.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/a9d3cca3-6505-34d6-faf2-83d26e321fc3.png)

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

![downloaded_artifact.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/eaeb2aba-0d40-3b53-79d6-46f3f318f541.png)

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

# デバッグ実行したい

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

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

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

![configuration_spinner.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/3ed47fb1-a995-4bda-c0c0-5bf523809cc1.png)

- [Edit Configurations...]を選ぶ

![edit_configurations.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/9494095d-11fe-e19f-4481-c6ca1b5862d6.png)

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

![copy_main.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/8384cfd8-a71e-ba31-652f-4a940f59ab4b.png)

- 設定をする
  - **Name:** `main.dart(test)`など
  - **Dart entry point:** `test_driver`下のアプリ起動ファイル(`_test`が付かない方)
  - **Additional arguments:** `--observatory-port 8888 --disable-service-auth-codes`をセット
  
![test_app_config.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/e134dd28-b5ec-ad15-142d-97000275be07.png)

- [OK]を押して保存

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

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

![add_tool.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/79ec122b-f735-92f0-f062-c9b9e2ac936d.png)

- **Dart Command Line App**を選ぶ

![dart_commandline_tool.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/3e81e92a-07be-c09a-fe3a-02a8d301ebfa.png)

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

![test_config.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/fb0cba6c-707a-86f1-9fb1-ca3cb3b9e2cf.png)


## 3.デバッグ実行

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

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

![run_test_app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/2ae9b1e7-ac5a-d2c2-21f0-9ab29d2b8538.png)

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

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

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

![run_test.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/292e9de1-8448-f13d-1cb0-894acf51c3d5.png)

### (3)ブレークポイント

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

![breakpoints.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103886/4e6a90da-6eaa-2bad-b535-76a280bed31f.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