33
14

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 1 year has passed since last update.

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

Posted at

はじめに

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

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

1. ホットリスタートを使って実装する

テスト実装中は以下のコマンドで実行しましょう。

flutter run -t integration_test/main_test.dart

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

flutter test integration_test

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

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

output.gif

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

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

DBからの取得は毎回必ず早いとは限らないので、tester.pumpAndSettle()で描画待ちすると、時々失敗するFlakyなtestが誕生します。
以下のようにDBから取得する処理でFuture.delayedを入れて擬似的に遅延を再現すると描画待ちできずにテストが失敗することが確認できるはずです。

// 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が見つかるまで繰り返し画面を確認し、見つかったタイミングで関数が終了します。

// 描画待ちしたい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を見つけるためのものです。テストの中では必ずと言っていいほど登場します。

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

3-1 RichTextを活用する

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

スクリーンショット 2023-10-30 23.02.23.png

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で実装するとコードがスッキリするだけではなく、テストでも効果を発揮します。

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

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

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

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だけを取り出すことができます。

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

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

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

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

@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のかわりに使うだけです。

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

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

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

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

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

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

スクリーンショット 2023-10-31 0.03.14.png

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

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

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

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

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

@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を実装します。

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コマンドで実行します。

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

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

`

まとめ

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

33
14
0

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
33
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?