【Flutter】インテグレーションテストでスクリーンショットを自動で取得する。

Flutterを使って個人で以下のスマホ向けのおみくじアプリ（大御心アプリ）を開発・運用しています。

<img width="130" alt="Screenshot_home.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/d4a6102b-53cd-eb2c-f333-5e23392e7f29.png">
<img width="130" alt="Screenshot_fortune_1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/ccc35e8e-f92e-308a-3fb8-8d194e2edc16.png">
<img width="130" alt="Screenshot_fortune_2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/c6959d28-9900-9789-84b1-d909607acc40.png">
<img width="130" alt="Screenshot_emperor_list.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/34b47a38-04e2-1ac9-baf8-6da0e8848086.png">
<img width="130" alt="Screenshot_emperor_detail.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/d68400a0-2926-1b1c-764f-76efaa7729e3.png">


[iOS]
https://apps.apple.com/us/app/%E5%A4%A7%E5%BE%A1%E5%BF%83%E3%82%A2%E3%83%97%E3%83%AA/id1627544916

[Android]
https://play.google.com/store/apps/details?id=jp.sikisimanomiti.oomigokoro

初めてAppleにiOSアプリとして審査へ提出した際に「App Review」で下記のメッセージとスクリーンショットがあって、リジェクトになりました。

```
Guideline 4.0 - Design


We noticed that several screens of your app were crowded or laid out in a way that made it difficult to use your app.

Next Steps

To resolve this issue, please revise your app to ensure that the content and controls on the screen are easy to read and interact with.

Resources
（・・・以下略・・・）
```

<img width="400" alt="Screenshot-0604-090902.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76958/a1c976bd-bd94-7bac-81ab-4937b7a29cf8.png">

赤丸をつけて文字が見切れている箇所をわざわざ教えてくれました。

Apple内部で実際どのような審査を行なっているのか分からないですが、上記メッセージを見る限り、様々な端末で動作チェックをしていると感じました。

同様な指摘をまた受けたくなかったので、Appleが様々な端末で動作チェックをしているなら、自分も同様に様々な端末で画面表示をチェックしなければならないということで、画面サイズの異なるiPhoneとiPadのシミュレータを立ち上げて画面表示をチェックしました。

しかし、シミュレータを立ち上げるのに多少時間がかかるのと、iPhoneとiPadで確認すべき画面サイズも15パターンぐらいあり、また画面を修正するたびに実施しなければならないので、手動で行うのは地味に面倒くさいと思いました。

自動でシミュレータを立ち上げアプリを起動し画面を開いてスクリーンショットを撮れれば、自分は撮ったスクリーンショットを確認するだけなので、楽できると思い、Flutterのintegration testingを使って自動でアプリのスクリーンショットを撮る仕組みを作りました。

# 環境
| ツール | バージョン |
|:-:|:-:|
| Mac | macOS Monterey 12.3.1 |
| Flutter | stable 3.0.2 |
| Android Studio | 2021.2 |
| Xcode | 13.3.1 |


# 手順
## 1.パッケージの追加
`dev_dependencies`に`integration_test`を追加します。

```:pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter

  integration_test:
    sdk: flutter
```

## 2.テストファイルを作成
プロジェクトルートの直下に、`test_driver`というフォルダを作成します。

自動で実施したい画面操作を以下のmain_test.dartのようにテストファイルに記述し、作成したフォルダに格納します。

```dart:main_test.dart
import 'dart:io';

import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  late FlutterDriver driver;
  const path = "test_driver/screenshots";

  setUpAll(() async {
    driver = await FlutterDriver.connect();
    final health = await driver.checkHealth();
    if (health.status == HealthStatus.bad) {
      fail("FlutterDriverの起動に失敗");
    }
    sleep(const Duration(seconds: 3));
  });

  tearDownAll(() async {
    driver.close();
  });

  test("take home screenshot", () async {
    String device = await driver.requestData("");

    await _doScreenShot(driver, path, "$device-home");

    // テストがアイコンをタップできるようタップしたいiconに「fortune-icon」というキーを設定している
    await driver.tap(find.byValueKey('fortune-icon'));

    sleep(const Duration(seconds: 3));
  });

}

/// スクリーンショットを取得する.
Future<void> _doScreenShot(
    FlutterDriver driver, String path, String fileName) async {
  await driver.waitUntilNoTransientCallbacks();
  final picture = await driver.screenshot();
  final file = File("$path/$fileName.png");
  await file.writeAsBytes(picture);
}
```

また環境変数に端末の名称を設定し、main関数で取得してテストに返却することで、スクリーンショットの画像ファイルに撮影した端末の名称も付与できるようにしています。後程、画面表示に問題があった場合に問題があった端末を特定できるので。

```dart:main.dart
Future<void> main() async {
  enableFlutterDriverExtension(handler: (request) async {
    return const String.fromEnvironment('DEVICE');
  });

  runApp(const MyApp());
}
```

上記ファイルが作成できたら、たとえば、Androidのシミュレータを立ち上げて、`flutter drive --target=lib/main.dart --dart-define="DEVICE=Pixel_3a"`コマンドを叩くと、環境変数DEVICEに指定した「Pixel_3a」をプレフィックスにしてスクリーンショットの画像ファイル（例：Pixel_3a-home.png）を作成します。


## 3.シェルの作成

2で作成したインテグレーションテストを様々な端末で一気に実行したいので、プロジェクトルートに以下のシェルを作成します。
変数のandroid_devicesとios_devicesにはインテグレーションテストを実行したデバイスをIDと名前の組み合わせで設定します。

Androidは作成したAVDのidを、iOSは`xcrun simctl list`を実行結果の「Devices」にあるIDを設定します。

```shell:integration_test.sh

#!/bin/sh

android_devices=$(cat << EOA
[
  {
    "id": "Pixel_3a_API_30",
    "name": "Pixel_3a_5.6"
  }
]
EOA
)

ios_devices=$(cat << EOI
[
  {
    "id": "3825C629-9256-479A-A0C4-255CAA84C746",
    "name": "iPhone_SE_3rd_4.7"
  }
]
EOI
)

DEVICE_ID=""
DEVICE_NAME=""

# Android
for android_device in $(echo $android_devices | jq -c '.[]'); do
  DEVICE_ID=$(echo $android_device | jq .id | sed -e 's/^"//' -e 's/"$//')
  DEVICE_NAME=$(echo $android_device | jq .name | sed -e 's/^"//' -e 's/"$//')

  sleep 5s
  // エミュレータを起動
  flutter emulators --launch $DEVICE_ID
  sleep 5s
  // テスト実行
  flutter drive --target=lib/main.dart --dart-define="DEVICE=$DEVICE_NAME"
  adb emu kill
done

sleep 30s

# iOS
for ios_device in $(echo $ios_devices | jq -c '.[]'); do
  DEVICE_ID=$(echo $ios_device | jq .id | sed -e 's/^"//' -e 's/"$//')
  DEVICE_NAME=$(echo $ios_device | jq .name | sed -e 's/^"//' -e 's/"$//')

  sleep 5s
  // シミュレータを起動
  open -a Simulator --args -CurrentDeviceUDID $DEVICE_ID
  sleep 5s
  // テスト実行
  flutter drive --target=lib/main.dart --dart-define="DEVICE=$DEVICE_NAME"
  killall "Simulator"
done
```

上記全てのファイルが作成できたら、プロジェクトルートで
`sh integration_test.sh`を叩くだけで複数端末で同じインテグレーションテストを実行できるようになります。

もし、`jq: command not found`というエラーが発生した場合は、`brew install jq`で`jq`をインストールして下さい。

# 感想
以上の仕組みを作ったおかげで、シェルを叩いて食事に出掛けて帰って来ると、スクリーンショットが撮り終わっている状態になってだいぶ楽できるようになりました。↑のAppleからの指摘にも対処でき、無事審査も通過・リリースできました。

めでたし、めでたし。