【Flutter】IntegrationTestの準備
概要
Flutterアプリで自動テストを導入したく、いつの間にかFlutterでリリースされていたIntegration Test
で試してみました。
対象はiOSを想定します。
目標としては、
- まずは導入したい
- Simulator上でアプリを動かしたい
- 主に気になる部分としては、スクロールやキーボード入力
- テストのエビデンスとしてスクリーンショットを撮影したい
環境は以下になります。
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.0.5, on macOS 12.5.1 21G83 darwin-x64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 30.0.3)
[✓] Xcode - develop for iOS and macOS (Xcode 14.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2021.1)
[✓] VS Code (version 1.74.0)
[✓] Connected device (3 available)
[✓] HTTP Host Availability
まずは導入
とりあえずGithubからやったことをまとめます。
1. pubspec.yamlに以下追加。
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
2. テスト記述ファイルの作成
integration_test/
というフォルタを作成(libなどと同じ階層)。
integration_test/main_test.dart(mainの部分はリネーム可)ファイルを作成。
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
// これは必須で必要。IntegrationTest用に初期化する。
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// テストをいろいろ書くところ
testWidgets("failing test example", (WidgetTester tester) async {
expect(2 + 2, equals(5));
});
}
3. Driverのエントリーポイントを追加
test_driver/integration_test.dart
というファイルを作成。
以下を追加します。(test_driver
はlib
と同じ階層)
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();
4. 以下のコマンドでテストを実施
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/main_test.dart
(main_test.dartの部分は2で作成した~~_test.dartの名前に適宜変更してくだだい)
これでとりあえずはテスト実行できるようになりました。
IntegrationTestを書いてみる
flutter create
で生成できる最初の状態でIntegrationTestを書いてみます。
integration_test/main_test.dartの
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
よりの下に実装していきます。
// 1. testWidgets
testWidgets(
'failing test example',
(tester) async {
// 2. app.main();
app.main();
// 3. tester.pumpAndSettle();
await tester.pumpAndSettle();
// 4. find.text('0')
// 5. findsOneWidgets
// 6. expect
expect(find.text('0'), findsOneWidget);
// 7. tester.tap
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
},
);
1. testWidgets
testWidgetsはテストを実行する関数。
void testWidgets(String description, Future<void> Function(WidgetTester) callback)
description ・・・ テスト名
callback ・・・ テスト本体
option系の引数は省略。
2. app.main();
アプリのmain関数を呼び出します。
このmain関数はflutter run
コマンドを叩いたときに最初に実行される最初のmain関数と同じものです。
そのため、テストを実装するファイル(~~~_test.dart)にimportする必要があります。
import 'package:[flutter create ~~~で入力した名前]/main.dart' as app;
as app
と書くことで、app.main();
という書き方ができ、~~~_test.dartに定義されているmain関数とは別物として扱うようにしています。
3. tester.pumpAndSettle();
描画処理が完了するまで待機しますよ、という関数です。
この関数をawaitすることで、描画が完了するまで次の行に行かずに待機します。
今回の流れ的には、アプリのmain関数が呼ばれたあと、最初に表示される画面が表示されるまで待機しています。
4. find.text('0')
画面上から0という文字列を探します。戻り地はFinder
という型で検索結果が返却されます。
もちろん0という文字列じゃなくいても、testの文字列を探したい場合は`.text('test')'になります。
また、textの他にもfind.byType(FloatingActionButton)
などといったWidgetのクラス名やtooltipでも検索することが可能です。
5. findsOneWidget
find.xxxの関数で探したWidgetが画面上にどれぐらい存在するかの個数を指定する定数です。
findsOneWidget
は画面上1つのみということを意味しています。
他には以下の定数が存在します。
- findsNothing ・・・ 画面上に存在しない
- findsWidgets ・・・ 一つ以上存在する
- findsNWidgets(int n) ・・・ nを引数に指定できます。
6. expect(dynamic actual, dynamic matcher)
(optinalの引数は省略)
4で探したWidgetsと5で指定した条件が一致しているかを確認する関数です。
expect関数がテストの成否判定を行う関数になります。
testWidgets内のexpect関数が失敗するとテスト失敗として判定されます。
7. tester.tap(Finder finder)
(optinalの引数は省略)
アプリで一番良く使うであろう画面をタップするを行う関数です。
finderには4などで取得したFinderを指定することで、Simulator上でタップしたときと同じ挙動を行います。
また、タップ後をして何かしらの挙動がアプリケーション内で行われますが描画に反映させるには、
3で行ったtester.pumpAndSettle();
で描画反映終了まで待機してあげる必要があります。
ひとまずこれで基本的なIntegrationTestができるようになりました。
Simulator上でアプリを動かしたい
ここからはSimulator上でアプリをごりごり動かしてみます。
ボタンタップ系については「Simulator上でアプリを動かしてみる」で動いたので、以下の観点でアプリを動かしてみたいと思います。
- スクロールさせる
- キーボード入力、キーボードが表示されたときのUI確認
スクロールさせる
スクロール可能なPageやViewでスクロールのテストを行う場合の方法を調べてみました。
testWidgets関数のcallbackの引数にWidgetTester tester
がありますが、メンバ関数に以下4つの関数がありました。
- drag(Finder finder, Offset offset)
- dragFrom(Offset startLocation, Offset offset)
- timedDrag(Offset offset, Duration duration)
- timedDragFrom(Offset startLocation, Offset offset, Duration duration)
dragとtimedDragの違いは一瞬でoffsetまで移動するか、duration
で設定した時間をかけてスクロールするかの違いです。
また~~Fromがついている場合は引数から察せるかと思いますが、スクロールを開始するスタート地点を指定できることでした。
Offsetの方向は-が下/右、+が上/左になります。そのためOffset(0, -1000)
と指定すると下方向に1000ポイントスクロールします。
ちなみに最下部までスクロールのテストをする場合はfinderにセットするスクロールViewの最大スクロール値を取得する必要があります。
その場合は、SingleScrollViewやListViewなどにScrollControllerを設定する必要があります。
例)
SingleChildScrollView(
controller: SingleController(),
child: Column(children:[/* example */]),
)
その後、テスト側で次の方法で最大スクロール値を取得します。
final scrollView = find.byType(SingleChildScrollView);
final s = scrollView.evaluate().first.widget as SingleChildScrollView;
// ここに最大スクロール値が入ります。
print(s.controller!.position.maxScrollExtent);
// これでScrilViewの最下層までスクロールします。
tester.drag(scrollView, Offset(0, -s.controller!.position.maxScrollExtent));
また、上記のdrag関数とは勝手が違う関数があります。
- dragUntilVisible(Finder finder, Finder view, Offset moveStep)
この関数はfinder
のWidgetを見つけるまで,view
のWidgetをmoveStep
ずつスクロールする関数です。
(シンプルな関数でドキュメントに実装まで載っていました)
なので例えば、利用規約画面などで一番下までスクロールして有効になる同意ボタンがあるような画面の場合だと、
- ボタンが見えるようになるまで画面をスクロール
- ボタンが見えたら、有効/無効かの判定
- ボタンをタップして画面遷移を確認
みたいなテストもできそうでした。
キーボード操作
なにかを入力する場合にスマートフォンだとキーボードが表示され、キーボードが表示されたときに画面がせり上がったりとレイアウトが変化する場合があるかと思います。
そういった点もIntegrationTestで確認できるのかどうか試してみました。
'WidgetTester'クラスにshowKeyboardという関数がありました。
引数にFinderを指定しますが、Finderに設定したWidgetにフォーカスを当てた状態でキーボードを表示する関数になります。
そのため、showKeyboardの引数にはTextField
やTextFormField
などをしています。
final emailAddressTextField = find.byType(TextField);
await tester.showKeyboard(emailAddressTextField);
await tester.pumpAndSettle();
キーボードを表示させたあと、テキストフィールドに文字を入力したいのですがキーボード一つひとつをタップする機能はありません。
その代わりにWidgetTesterにenterTextという関数が用意されています。
enterText(Finder finder, String text)
実装例
await tester.enterText(emailAddressTextField, 'example@example.jp');
await tester.pumpAndSettle();
finder
で指定したWidget
に対して、text
の文字列を反映させます。
その後、キーボードのdone
をタップしたときにキーボードが隠れてレイアウトが元に戻ることをテストするために、done
ボタンをタップしたのと同じ挙動をさせたいです。
WidgetTester
はキーボードへイベント発行ができないため、TestTextInput
というクラスを利用します。
final testInput = TestTextInput();
await testInput.receiveAction(TextInputAction.done);
receiveActionの関数はキーボードで発行されるイベントを受け取ったことにする関数です。
receiveAction(TextInputAction.done)
の場合はキーボードでdoneが押されたことイベントを受け取り、それをアプリに反映させます。
テストのエビデンスとしてスクリーンショットを撮影
IntegrationTest中、Simulatorを凝視しているのもいいかもですが、どうせならエビデンスで画像などを残せないかと思い調べてみました。
なにかFlutterの中でやり取りをしているっぽいですが、上記Issusの内容を確認しつつ以下ファイルを修正することでスクリーンショットが取れるようになりました。
takeScreenshotという関数で撮影ができるたので使えるようにしていきたいと思います。
- AppDelegate.swift
- test_driver/integration_test.dart
- integration_test/~~~~_test.dart
まずAppDelegate.swift
では以下の2行をreturn
前に追加します。
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
IntegrationTestPlugin.instance().setupChannels(controller.binaryMessenger)
また、importを一つ追加します。
import integration_test
test_driver/integration_test.dartでは、
integrationDriverにあるonScreenshotの実装を行います。
この関数はdriverのtakeScreenshot関数が呼ばれた場合にそのbytesを取得しファイルとして保存します。
import 'dart:io' as dartio;
import 'package:integration_test/integration_test_driver_extended.dart'; // <- 追加
Future<void> main() async {
try {
await integrationDriver(
onScreenshot: (String screenshotName, List<int> screenshotBytes) async {
// bytes配列を受け取ってファイルとして保存します
final dartio.File image =
await dartio.File('screenshots/$screenshotName.png')
.create(recursive: true);
image.writeAsBytesSync(screenshotBytes);
return true;
},
);
} catch (e) {
print('Error occured: $e');
}
}
最後にtakeScreenshotを呼び出します。
そのために、integration_test/main_test.dart
を修正していきます。
main関数直後にIntegrationTestWidgetsFlutterBinding()
を生成します。
final binding = IntegrationTestWidgetsFlutterBinding();
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
そして、撮影したいタイミングで以下の関数を呼び出します。
takeScreenshot(String filename)
filenameは.png
を除いた名称を指定します。
await binding.takeScreenshot('textfield_tapped_test');
takeScreenshotを呼び出しテストを完了させると、
lib
と同じ階層にscreenshots
というフォルダが作成され、その中に撮影された画像が保存されるようになります。
ただ一つ欠点があり、takeScreenshotはあくまでFlutterで描画している範囲を撮影するものでありキーボードはSimulator側で表示させているものなので、撮影できませんでした。。。
もしキーボード含めて撮影したい場合はテスト実行前に以下のコマンドでSimulatorごと撮影してしまうのもありかと思いました。
xcrun simctl io booted recordVideo movie.mov
今の所キーボードが表示されたときにはレイアウトが変わっていることを確認できればいいと思っているので、ひとまずはスクリーンショットのみで満足しています。
Discussion