はじめに
Integration Testを実装していく中で最初に知っておきたかったと思ったことをまとめてみました。これまで実装してきた方やこれから実装する人にも参考になる情報があれば嬉しいです。
ちなみに、Integration Testとは結合テストのことです。
ビルドしたアプリでテストを実行するので単体テストよりも実際の操作に近い環境でテストを実行できます。
1. ホットリスタートを使って実装する
テスト実装中は以下のコマンドで実行しましょう。
flutter run -t integration_test/main_test.dart
integration testの実行は、以下のコマンドで実行するように紹介されています。
flutter test integration_test
test
コマンドで実行するとビルド
→実行
→終了
→ビルド
→実行
→終了
のように、動作確認で実行するたびに毎回ビルドが発生するので待ち時間が発生します。
実装完了したテストを実行するときはこれで良いのですが、実装中は効率が悪いです。
flutter run
コマンドを使うとshift+r
でホットリスタートが使えるので爆速で実行できます。
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を見つけることができません。
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')
注) Text.rich
ではなくRichText
を使用する場合はfind.text('More Information Here',findRichText: true)
としてください。
If
findRichText
is false, all standalone [RichText] widgets are
ignored andtext
is matched with [Text.data] or [Text.textSpan].
3-2 descendant+matchRoot
widgetをand条件で見つけたいときにdescendant
とmatchRoot
を活用できます。
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を作る
以下のようにtestWidgets
をwrapper
する関数を作っておくことをおすすめします。
@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でテストとして判定されるので、Run
やDebug
などのボタンが表示されるようになります。
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を実装します。
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で使えるテクニックをまとめてみました。
これは使える!と思ったものがあれば是非使ってみてください。
また、もっと良いテクニックがあれば教えてください!