【Flutter】Patrolでテストを書く（テスト編）

## はじめに

[【Flutter】Patrolでテストを書く（導入まで）](https://qiita.com/yyyy_yk/items/371c9ef67e81e39a7f58) の続きです。
実際に`patrol`で統合テストを書いてネイティブビューを操作してみたいと思います。

※ [Finder](https://api.flutter.dev/flutter/flutter_test/Finder-class.html)や[WidgetTester](https://api.flutter.dev/flutter/flutter_test/WidgetTester-class.html)の基本的な使い方については触れません。

## 環境

・flutter: 3.19.6
・dart: 3.3.4
・patrol: 3.6.1
・patrol_cli: 2.7.0

## 基本のテスト

まずはFlutterプロジェクト作成時にデフォルトで用意されているDemoアプリに対してテストを書いてみます。
↓ Demoアプリというのはコレのことです。
<img width=200 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/906801/07f22eb4-e530-b945-92fb-c9334112f847.png>

### patrolを使わない場合

`patrol`でもFinderやWidgetTesterは使えます。
なので、既に[integration_test](https://docs.flutter.dev/testing/integration-tests)でテストを書いている場合でも比較的低コストで`patrol`へ切り替えることができそうです。
まずはpatrolを使用せずテストを書いてみます。
導入編で作成した`integration_test`ディレクトリに`demo_test.dart`を作成してテストを追加します。

```demo_test.dart
void main() {
  patrolTest('demo', (PatrolIntegrationTester $) async {
    await $.pumpWidgetAndSettle(const MyApp());
    
    expect(
      find.text('Flutter Demo Home Page'),
      findsOneWidget,
    );

    expect(
      find.text('0'),
      findsOneWidget,
    );

    final button = find.byType(FloatingActionButton);

    expect(
      button,
      findsOneWidget,
    );

    await $.tester.tap(button);

    await $.tester.pumpAndSettle();

    expect(
      find.text('1'),
      findsOneWidget,
    );

    await $.tester.tap(button);

    await $.tester.pumpAndSettle();

    expect(
      find.text('2'),
      findsOneWidget,
    );
  });
}
```

`$.tester`で`WidgetTester`を使用できます。
テストを書いたら以下のコマンドで統合テストを実行します。
注意点として、名前は`demo_test.dart`である必要はありませんが、対象は`integration_test`内にある`_test.dart`で終わるファイル名でないといけません。

・Android
```
patrol test --target integration_test/demo_test.dart -d emulator-5554
```

・iOS
```
patrol test --target integration_test/demo_test.dart -d 'iPhone 15'
```

### patrolを使う場合

次にpatrolを使用してテストを書き直してみます。

```demo_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:patrol_example/main.dart';

void main() {
  patrolTest('demo', (PatrolIntegrationTester $) async {
    await $.pumpWidgetAndSettle(const MyApp());

    expect(
      $('Flutter Demo Home Page'),
      findsOneWidget,
    );

    expect(
      $('0'),
      findsOneWidget,
    );

    final button = $(FloatingActionButton);

    expect(
      button,
      findsOneWidget,
    );

    await button.tap();

    // 「1」というテキストを含むWidgetが表示されるまで待機
    await $('1').waitUntilVisible();

    expect(
      $('1'),
      findsOneWidget,
    );

    await button.tap();

    await $('2').waitUntilVisible();

    expect(
      $('2'),
      findsOneWidget,
    );
  });
}
```

正直簡単過ぎるテストなのであまり変わったように見えませんが、少し簡潔になった気がします。
他にも例えば、単純なDemoアプリではメリットを感じませんが、実際のアプリではHTTP通信等の非同期処理があり、`tester.pumpAndSettle()`だけでは描画待ちできないケースもあると思います。
こういったケースでも`patrol`が使えると便利です。

試しに、Demoアプリのボタンタップ時、インクリメントする処理にdelayを追加してください。
```lib/main.dart
      floatingActionButton: FloatingActionButton(
        onPressed: () async {
          // これを追加
          await Future.delayed(const Duration(seconds: 3));
          _incrementCounter();
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
```

その後、`patrolを使わない場合`で書いたテストを実行するとテストは失敗します。
ボタンタップ時、カウントが「0」 -> 「1」に描画されるのを`tester.pumpAndSettle()`で待機できないからです。
次に`patrolを使う場合`で書いたテストを実行してみるとテストが成功すると思います。
`$('1').waitUntilVisible()`が「1」というテキストが描画されるまで待機してくれるからです。

他のPatrol finderやアクションの使用方法・メリットついては以下が参考になると思います。
https://patrol.leancode.co/finders/usage

## ネイティブビューのテスト

次にOSの権限ダイアログに対してテストを書きます。
雑に[permission_handler](https://pub.dev/packages/permission_handler) を使用してボタン押下時に位置情報の権限許可ダイアログを表示するようにしました。
ダイアログから権限を設定すると`Text`に権限状態を表示するようにしてます。

```.dart

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;
  PermissionStatus? _status;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  Future<void> _updateLocationStatus() async {
    PermissionStatus status = await _getLocationStatus();
    setState(() {
      _status = status;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            // ----- 追加 ----- //
            TextButton(
              onPressed: () =>
                  _requestLocation().then((_) => _updateLocationStatus()),
              child: const Text('ボタン'),
            ),
            if (_status != null)
              Text(
                '$_status',
              ),
            // --------------- //
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }

  Future<void> _requestLocation() async => Permission.location.request();

  Future<PermissionStatus> _getLocationStatus() async =>
      await Permission.location.status;
}
```

テストがこちらです。
ネイティブビューの操作に対しては`$.native`を使用します。
注意点として、iOSで権限ダイアログを操作する場合、言語設定は`English(US)`にする必要があります。それ以外の言語では権限をうまく選択することができません。

一応、`English(US)`以外の言語でテストする方法もあるみたいです

https://patrol.leancode.co/native/usage

```.dart
void main() {
  patrolTest('demo', (PatrolIntegrationTester $) async {
    await $.pumpWidgetAndSettle(const MyApp());

    // ----- 省略 ----- //

    await $('ボタン').tap();

    // 権限ダイアログが表示されるまで待機
    await $.native.isPermissionDialogVisible();
    // 「使用中のみ許可」を選択
    await $.native.grantPermissionWhenInUse();
    // 権限状態が表示されているWidgetを探す
    final status = $('${await Permission.location.status}');

    await status.waitUntilVisible();

    expect(
      status,
      findsOneWidget,
    );
  });
}
```

## テスト実行時にエラーになった場合

私が遭遇したエラーと解決方法についてメモしておきます。
公式の[FAQ](https://patrol.leancode.co/getting-started#faq)を見たり、エラーメッセージで[issues](https://github.com/leancodepl/patrol/issues)を検索すると似たようケースが結構ありました。

### Unsupported class file major version 65

- エラーメッセージ

```
FAILURE: Build failed with an exception.
        
* What went wrong:
Execution failed for task ':gradle:compileGroovy'.
> BUG! exception in phase 'semantic analysis' in source unit '/Users/username/fvm/versions/3.19.6/packages/flutter_tools/gradle/src/main/groovy/app_plugin_loader.groovy' Unsupported class file major version 65
```

これについてはFAQに記載がありました。
> It's most likely caused by using incompatible JDK version. Run javac -version to check your JDK version. Patrol officially works on JDK 17, so unexpected errors may occur on other versions. If you have AndroidStudio or Intellij IDEA installed, you can find the path to JDK by opening your project's android directory in AS/Intellij and going to Settings -> Build, Execution, Deployment -> Build Tools -> Gradle -> Gradle JDK. Learn more

要するに`Patrol`はJDK 17で動くことを想定してるので他のバージョンだとエラーが発生するかもとのこと。

- 自分の環境を確認したところ、やはり17ではありませんでした。
```
javac -version
> javac 21.0.1
```

- 17をインストール
```
sdk list java
sdk install java 17.x.x-open
```

- 17に切り替え
```
sdk use java 17.x.x-open
```

- Android StudioのGradle JVM設定確認
0. `android/build.gradle`からAndroid Studioを開く
0. Settings > Build, Execution, Deployment > Build Tools > Gradle に移動
0. Gradle JDK に17.x.x-open バージョンが設定されていることを確認

## おわりに

基本的な使い方は以上です。ネイティブビューを操作できること以外にも色々できるので時間があれば試してみようと思います。
難点としては、テスト時のスクリーンショットを撮ることはできない（以前のバージョンにはあったみたいです）ので、スクショが目的なら`patrol`は選択肢から外れてしまいそうです。

## 参考
・[Patrol Finders Usage](https://patrol.leancode.co/finders/usage)
・[Patrol CLI commands test](https://patrol.leancode.co/cli-commands/test)