🌟

【Flutter】IntegrationTestの準備

2022/12/25に公開

概要

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

まずは導入

https://github.com/flutter/flutter/tree/main/packages/integration_test#ios-device-testing
とりあえず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_driverlibと同じ階層)

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ずつスクロールする関数です。
(シンプルな関数でドキュメントに実装まで載っていました)
https://api.flutter.dev/flutter/flutter_test/WidgetController/dragUntilVisible.html

なので例えば、利用規約画面などで一番下までスクロールして有効になる同意ボタンがあるような画面の場合だと、

  1. ボタンが見えるようになるまで画面をスクロール
  2. ボタンが見えたら、有効/無効かの判定
  3. ボタンをタップして画面遷移を確認
    みたいなテストもできそうでした。

キーボード操作

なにかを入力する場合にスマートフォンだとキーボードが表示され、キーボードが表示されたときに画面がせり上がったりとレイアウトが変化する場合があるかと思います。
そういった点もIntegrationTestで確認できるのかどうか試してみました。

'WidgetTester'クラスにshowKeyboardという関数がありました。
引数にFinderを指定しますが、Finderに設定したWidgetにフォーカスを当てた状態でキーボードを表示する関数になります。
そのため、showKeyboardの引数にはTextFieldTextFormFieldなどをしています。

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ボタンをタップしたのと同じ挙動をさせたいです。
iOS Keyboard
WidgetTesterはキーボードへイベント発行ができないため、TestTextInputというクラスを利用します。

final testInput = TestTextInput();
await testInput.receiveAction(TextInputAction.done);

receiveActionの関数はキーボードで発行されるイベントを受け取ったことにする関数です。
receiveAction(TextInputAction.done)の場合はキーボードでdoneが押されたことイベントを受け取り、それをアプリに反映させます。

テストのエビデンスとしてスクリーンショットを撮影

IntegrationTest中、Simulatorを凝視しているのもいいかもですが、どうせならエビデンスで画像などを残せないかと思い調べてみました。

https://github.com/flutter/flutter/issues/91668

なにか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