【Flutter】Integration Testを極めるためのテクニック集

# はじめに
Integration Testを実装していく中で最初に知っておきたかったと思ったことをまとめてみました。これまで実装してきた方やこれから実装する人にも参考になる情報があれば嬉しいです。

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

ちなみに、Integration Testとは結合テストのことです。
ビルドしたアプリでテストを実行するので単体テストよりも実際の操作に近い環境でテストを実行できます。

# 1. ホットリスタートを使って実装する
テスト実装中は以下のコマンドで実行しましょう。

```sh
flutter run -t integration_test/main_test.dart
```

integration testの実行は、以下のコマンドで実行するように紹介されています。

```sh
flutter test integration_test
```

`test`コマンドで実行すると`ビルド`→`実行`→`終了`→`ビルド`→`実行`→`終了`のように、動作確認で実行するたびに毎回ビルドが発生するので待ち時間が発生します。
実装完了したテストを実行するときはこれで良いのですが、実装中は効率が悪いです。

`flutter run`コマンドを使うと`shift+r`でホットリスタートが使えるので爆速で実行できます。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/d9d2330f-39f3-13a6-a4b3-d6c96dc1a9dc.gif)

# 2. 非同期処理用の描画待ち関数を用意する

標準で描画待ち用の関数 ( [tester.pumpAndSettle()](https://api.flutter.dev/flutter/flutter_test/WidgetTester/pumpAndSettle.html) )がありますが、万能ではありません。この関数は指定された秒数（デフォルは100ミリ秒）ごとに画面を確認し、描画待ちのものがなくなるまで待つという動作をします。
通常の画面描画であれば良いのですが、DBからのデータ取得や複雑な処理など時間がかかる非同期処理によって画面が更新された場合に対応できません。

DBからの取得は毎回必ず早いとは限らないので、`tester.pumpAndSettle()`で描画待ちすると、時々失敗するFlakyなtestが誕生します。
以下のようにDBから取得する処理で`Future.delayed`を入れて擬似的に遅延を再現すると描画待ちできずにテストが失敗することが確認できるはずです。
```dart
// Widgetを描画するためにDBからデータ取得するための関数
Future<Detail> getDetail(){
  final details = await db.details.where().findAll();

  // 以下のように明示的にwaitを入れて描画を遅延させるとtester.pumpAndSettleで待てない
  // await Future.delayed(const Duration(seconds: 3));
  return detail;
}
```

解決方法として、非同期処理を待つための関数を用意しておきましょう。
以下の`pumpUntilFound`を使うと確実に描画待ちしたいWidgetの描画を待つ事ができるので、テストを安定して実行できます。
pumpUntilFoundは指定したWidgetが見つかるまで繰り返し画面を確認し、見つかったタイミングで関数が終了します。

```dart
// 描画待ちしたいWidgetのFinderを渡す
await tester.pumpUntilFound(find.byType(ListTile));

extension TestUtilEx on WidgetTester {
  Future<void> pumpUntilFound(
    Finder finder, {
    Duration timeout = const Duration(seconds: 10),
    String description = '',
  }) async {
    var found = false;
    final timer = Timer(
      timeout,
      () => throw TimeoutException('Pump until has timed out $description'),
    );
    while (!found) {
      await pump();
      found = any(finder);
    }
    timer.cancel();
  }
}
```

参考： https://github.com/flutter/flutter/issues/73355#issuecomment-805736745

# 3. Finderを使いこなす

Finderとはテストの中でwidgetが存在しているかを確認するために、様々な条件を指定してwidgetを見つけるためのものです。テストの中では必ずと言っていいほど登場します。

```dart
// Hというテキストを持つwidgetが一つ存在しているかチェック
expect(find.text('H'), findsOneWidget);
```

https://api.flutter.dev/flutter/flutter_test/Finder-class.html

## 3-1 RichTextを活用する
以下のようなウィジェットを実装にRowとTextを組み合わせて実装している人はいませんか？
見た目上は全然問題ありませんが、テストで`find.text('More Information Here')`としたくても、**widgetを見つけることができません。**

![スクリーンショット 2023-10-30 23.02.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/0e044b3e-6fe2-3183-b29c-665868868eee.png)

```dart
Row(
  textBaseline: TextBaseline.alphabetic,
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.baseline,
  children: [
    Text('More '),
    Text(
      'Information',
      style: TextStyle(
          fontSize: 24,
          fontWeight: FontWeight.bold,
          color: Colors.red),
    ),
    Text(' Here'),
  ],
);

// 以下のFinderでは見つからない
find.text('More Information Here')

// 以下のようにText WidgetごとにFindする必要がある
find.text('More')
find.text('Information')
find.text('Here')
```

これを解決するには、Text.richかRichTextを使うようにしましょう。
`Text.rich`で実装するとコードがスッキリするだけではなく、テストでも効果を発揮します。

https://api.flutter.dev/flutter/widgets/RichText-class.html

Row+Textでは使えなかった`find.text('More Information Here')`でwidgetを見つけられるようになります。

```dart
Text.rich(TextSpan(children: [
  TextSpan(text: 'More '),
  TextSpan(
    text: 'Information',
    style: TextStyle(
        fontSize: 24,
        fontWeight: FontWeight.bold,
        color: Colors.red),
  ),
  TextSpan(text: ' Here')
]));

// 以下のFinderで見つかる
find.text('More Information Here')
```

もちろん見た目はどちらも同じです。
![スクリーンショット 2023-10-30 23.05.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/de2bb2d4-2d3c-e39f-9b71-ac92b877dd74.png)



注) `Text.rich`ではなく`RichText`を使用する場合は`find.text('More Information Here',findRichText: true)`としてください。
> If `findRichText` is false, all standalone [RichText] widgets are
> ignored and `text` is matched with [Text.data] or [Text.textSpan].

## 3-2 descendant+matchRoot

widgetをand条件で見つけたいときに`descendant`と`matchRoot`を活用できます。
`descendant`は本来であれば指定したwidgetの子孫を見つけるときに使う`Finder`ですが、`matchRoot`というオプションを`true`にすると自分自身にもマッチさせることができます。
例えば、特定の`Key`と特定の文字列を持っているwidgetを見つけたいときには、以下のように使うことができます。

```dart
Text(key: Key('sample_item'), 'Sample Items');

expect(
  find.descendant(
    of: find.byKey(const Key('sample_item')),
    matching: find.text('Sample Items'),
    matchRoot: true
  ),
  findsOneWidget
);
```

厳密にwidgetを見つけたときに活躍してくれるはずです。

## 3-3 at
複数のwidgetが見つかってしまうときには`at`を使うとn個目のwidgetだけを取り出すことができます。

```dart
await tester.tap(find.byType(ListTile).at(1));

// children: [
//   ListTile...
//   ListTile...←これをタップ
//   ListTile...
// ];
```

# 4. 初期化処理を実行するtestWidgetsのwrapperを作る

以下のように`testWidgets`を`wrapper`する関数を作っておくことをおすすめします。

```dart
@isTest // package:meta/meta.dart
void myTest(
  String description,
  Future<void> Function(
    WidgetTester tester,
    // データベースのインスタンス
    Isar database,
  ) test, {
  bool? skip,
}) {
  testWidgets(description, (tester) async {
    // DB初期化処理
    final database = await initDatabase();
    await test(tester, database);
  });
}
```

使い方は以下のように`testWidgets`のかわりに使うだけです。

```dart
myTest('test1', (tester, database) async {
  // ここにテストを書く
});
```

このwrapperを作っておくメリットとしては、テスト関数の引数に自由に値を渡せることです。(第2引数のdatabase)

以下のように毎回初期化せずとも常に必要なインスタンスをすぐに使うことができます。
今回はデータベースのインスタンスを例に出しましたが、自由にカスタマイズできるのでテストで頻繁に使うものなどを渡すようにすると良いでしょう。

```dart
testWidgets(description, (tester) async {
  // 毎回DB用のインスタンスを作る必要がある。
  final database = await initDatabase();
  database.get();
});

myTest(description, (tester, database) async {
  // 渡ってくるので初期化不要
  database.get();
});
```

ちなみに、`@isTest`をつけておくとVSCodeなどのIDEでテストとして判定されるので、`Run`や`Debug`などのボタンが表示されるようになります。

![スクリーンショット 2023-10-31 0.03.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/52080c6a-9eac-6716-1508-1590e79eb0d0.png)


# 5. テストに失敗したときにスクリーンショットを撮る

手元で実行するときには困らないと思いますが、CI上で実行している場合にはスクリーンショットがないとエラーの理由を特定するのが困難なときがあります。

そこで、失敗時にスクリーンショットを取れるようにしておくことがおすすめです。

実装方法はここに載っています。

https://github.com/flutter/flutter/tree/master/packages/integration_test#screenshots


まず、一つ前の章で紹介したwrapper関数を拡張してエラーが出た場合にスクリーンショットを撮るようにします。

```dart
@isTest
void myTest(
  String description,
  Future<void> Function(
    WidgetTester tester,
    Isar database,
  ) test, {
  bool? skip,
}) {
  testWidgets(description, (tester) async {
    try {
      final database = await initDatabase();
      await test(tester, database);
    } catch (e) {
      final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
      if (Platform.isAndroid) {
        await binding.convertFlutterSurfaceToImage();
        await tester.pumpAndSettle();
      }
      await binding.takeScreenshot(description);
      rethrow;
    }
  });
}

```

スクリーンショットをファイルとして保存するためのDriverを実装します。

```dart:test_driver/integration_test.dart
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';

Future<void> main() async {
  await integrationDriver(
    onScreenshot: (String screenshotName, List<int> screenshotBytes,
        [Map<String, Object?>? args]) async {
      final File image = File('$screenshotName.png');
      image.writeAsBytesSync(screenshotBytes);
      // Return false if the screenshot is invalid.
      return true;
    },
  );
}

```

そして、`flutter test`コマンドではなく、`flutter drive`コマンドで実行します。

```sh
flutter drive --driver=test_driver/integration_test.dart --target=integration_test/main_test.dart
```

失敗時には自動で以下のようにスクリーンショットが撮影されます。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600358/adf87a71-0e4c-20a4-400b-633732851970.png' width='200px'/>`


# まとめ

FlutterのIntegration Testで使えるテクニックをまとめてみました。
これは使える！と思ったものがあれば是非使ってみてください。
また、もっと良いテクニックがあれば教えてください！