FlutterでProviderを使用しているアプリのテスト方法

# はじめに

この記事はGoogleのCodelabにて公開されている以下のチュートリアルの個人的なまとめです。
[Flutterアプリをテストする方法](https://codelabs.developers.google.com/codelabs/flutter-app-testing#0)

# テスト対象のアプリ
![testing_app.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311273/befbc9fc-5c6a-0a30-244a-9fb1bf534742.gif)
一覧ページとお気に入りページがあり、アイコンタップでお気に入り登録と解除ができるよくあるパターンのアプリです。
お気に入りの管理にProviderを使用しています。

### アプリのディレクトリ構成
testing_app/
┣ lib/
┃　┣ models/
┃　┃　┗ favorites.dart
┃　┗ screens/
┃　　　┗ favorites.dart
┃　　　┗ home.dart
┗ test/

中身のコードは以下を参照してください
[codelabのコード](https://github.com/flutter/codelabs/tree/master/testing_codelab/step_05/lib)

# テストファイルのディレクトリ構成

プロジェクト作成時に自動生成されているtestフォルダに追加していきます。
テストファイルを追加する際は、アプリ側の構成と合わせて配置するとわかりやすいです。
今回の場合は以下の通りです。
testing_app/
┣ ...
┗ test/
　┗ models/
　┃　　┗ favorites_test.dar
　┣ favorites_test.dart
　┗ home_test.dart

テストファイルにはlib側のファイル名 + _testで命名します。
**※テストランナーがテストファイルを検索する際に使用するようなので必ずつけましょう**

# ユニットテスト
単純な関数やメソッドに対するテストを行います。
計算処理や追加処理などが該当します。
今回は`models/favorites.dart`がテスト対象

### 準備
pubspec.ymlに以下を記載

``` pubspec.yaml
dev_dependencies:
  test: ^1.14.4
  flutter_test: ^1.0.1
```

### 実装

``` favorites_test.dart
import 'package:test/test.dart';
import 'package:testing_app/models/favorites.dart';

void main() {
  group('App Provider Tests', () {
    var favorites = Favorites();

    test('A new item should be added', () {
      var number = 35;
      favorites.add(number);
      expect(favorites.items.contains(number), true);
    });

    test('An item should be removed', () {
      var number = 45;
      favorites.add(number);
      expect(favorites.items.contains(number), true);
      favorites.remove(number);
      expect(favorites.items.contains(number), false);
    });
  });
}
```

#### テスト内容説明
・お気に入り追加テスト
・お気に入り削除テスト

#### 使用したメソッドなどの説明
・`group(description, body)`
　複数の関連するテストをまとめることができます。

・`test(description, body)`
　テスト本体です。

・`expect(actual, matcher)`
　結果を比較します。

### (CLI)テストを実行する
```
$ flutter test test/models/favorites_test.dart 
```
すべてOKだった場合の結果表示はこうなります。

```
00:06 +2: All tests passed!
```

### (GUI)テストを実行する
アイコンを押下するだけです。
![スクリーンショット 2021-03-28 12.13.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311273/259fb2cb-f24b-5851-1e76-34189c3ed463.png)
すべてOKだった場合の結果はこうなります。
![スクリーンショット 2021-03-28 12.23.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311273/54c364f1-0f6c-91e9-e2b0-1e9ce13a9656.png)

# ウィジェットテスト
ボタンタップでsnackbarが表示されるかなどのウィジェットの挙動をテストします。
テスト対象
　・`screens/favorites.dart`
　・`screens/home.dart`

### 実装

``` home_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:testing_app/models/favorites.dart';
import 'package:testing_app/screens/home.dart';

Widget createHomeScreen() => ChangeNotifierProvider<Favorites>(
      create: (context) => Favorites(),
      child: MaterialApp(
        home: HomePage(),
      ),
    );

void main() {
  group('Home Page Widget Tests', () {
    testWidgets('Testing if ListView shows up', (tester) async {
      await tester.pumpWidget(createHomeScreen());
      expect(find.byType(ListView), findsOneWidget);
    });

    testWidgets('Testing Scrolling', (tester) async {
      await tester.pumpWidget(createHomeScreen());
      expect(find.text('Item 0'), findsOneWidget);
      await tester.fling(find.byType(ListView), Offset(0, -200), 3000);
      await tester.pumpAndSettle();
      expect(find.text('Item 0'), findsNothing);
    });

    testWidgets('Testing IconButtons', (tester) async {
      await tester.pumpWidget(createHomeScreen());
      expect(find.byIcon(Icons.favorite), findsNothing);
      await tester.tap(find.byIcon(Icons.favorite_border).first);
      await tester.pumpAndSettle(Duration(seconds: 1));
      expect(find.text('Added to favorites.'), findsOneWidget);
      expect(find.byIcon(Icons.favorite), findsWidgets);
      await tester.tap(find.byIcon(Icons.favorite).first);
      await tester.pumpAndSettle(Duration(seconds: 1));
      expect(find.text('Removed from favorites.'), findsOneWidget);
      expect(find.byIcon(Icons.favorite), findsNothing);
    });
  });
}
```
#### テスト内容説明
上から順に
・お気に入り一覧(ListView)が表示されているか確認
・スクロールで'Item 0'が画面外に出たか確認
・favorite_borderアイコンをタップでお気に入り追加され、snackBarに追加メッセージが表示されることと、favoriteアイコンをタップでお気に入りから削除され、snackBarに削除メッセージが表示されることを確認

#### 使用したメソッドなどの説明
・`createHomeScreen()`
　Providerによる状態管理を再現するために使用します。

・`testWidget(description, callback)`
　ウィジェットテストの場合はこちらを使用します。

・`tester`
　アプリを操作するための変数です(`WidgetTest型`)。
　ボタンをタップ、スクロールなどはすべてtesterで行います。
　　・`pumpWidget(widget)`
　　　テスト対象のウィジェットを準備する。
　　・`fling(finder, offset, speed)`
　　　finderで指定したウィジェットをスクロールする。
　　・`tap(finder)`
　　　finderで指定したウィジェットをタップする
　　・`pumpAndSettle(duration)`
　　　ウィジェットの描画完了待機

・`find`
　flutter_testパッケージが提供するクラス(`CommonFinders`)
　ウィジェットを探すのに使用します。
　こちらも色々なメソッドが用意されています。
　　・`byType(type)`
　　　引数で渡された型に一致するウィジェットを探索し、`Finder`型で返す
　　・`byIcon(icon)`
　　　アイコンで探す。
　　・`byText(text)`
　　　引数のテキストと一致するテキストを保持しているウィジェットを探す。
　　
・expect()の第二引数について
　第一引数のfind結果と比較する定数です
　`findsNotiong` ウィジェットがfindツリー上に存在しない
　`findsOneWidget` ウィジェットがfindツリー上に一つだけ存する

### テストを実行する

※実行方法はユニットテストと同様なので割愛

# インテグレーションテスト
### 準備
pubspec.ymlに以下を追加します

``` pubspec.yml
dev_dependencies:
  integration_test: ^1.0.1
  flutter_driver:
    sdk: flutter
```

インテグレーションテストでは`testing_app/test`ディレクトリは使用しません。
ディレクトリとテストファイルを以下のように追加します。
testing_app/
┣ ...
┗ integration_test/　←new
　┣ driver.dart　←new
　┗ app_test.dart　←new

### 実装
``` driver.dart
import 'package:integration_test/integration_test_driver.dart';

Future<void> main() => integrationDriver();
```
・`integrationDriver()`
　flutter_driveでテストを実行するためのアダプターで、計測したパフォーマンスデータの出力なども行ってくれます。


``` app_test.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:testing_app/main.dart';

void main() {
  group('Testing App Performance Tests', () {
    final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized()
        as IntegrationTestWidgetsFlutterBinding;

    binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;

    testWidgets('Scrolling test', (tester) async {
      await tester.pumpWidget(TestingApp());

      final lastFinder = find.byType(ListView);

      await binding.watchPerformance(() async {
        await tester.fling(lastFinder, Offset(0, -500), 10000);
        await tester.pumpAndSettle();

        await tester.fling(lastFinder, Offset(0, 500), 10000);
        await tester.pumpAndSettle();
      }, reportKey: 'scrolling_summary');
    });

    testWidgets('Favorites operaitons test', (tester) async {
      await tester.pumpWidget(TestingApp());

      final iconKeys = [
        'icon_0',
        'icon_1',
        'icon_2',
      ];

      for (var icon in iconKeys) {
        await tester.tap(find.byKey(ValueKey(icon)));
        await tester.pumpAndSettle(Duration(seconds: 1));

        expect(find.text('Added to favorites.'), findsOneWidget);
      }

      await tester.tap(find.text('Favorites'));
      await tester.pumpAndSettle();

      final removeIconKey = [
        'remove_icon_0',
        'remove_icon_1',
        'remove_icon_2',
      ];

      for (final iconKey in removeIconKey) {
        await tester.tap(find.byKey(ValueKey(iconKey)));
        await tester.pumpAndSettle(Duration(seconds: 1));

        expect(find.text('Removed from favorites.'), findsOneWidget);
      }
    });
  });
}

```

#### 使用したメソッドなどの説明
・`ensureInitialized()`
　テストドライバーの初期化確認と最初期化などを行ってくれる
・`binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;`
　フレームのポンピング設定(fullyLiveの場合は意図的にポンピングしなくとも自動的にポンピングするようです)
・`watchPerformance()`
　パフォーマンスを計測して指定した名称でjson出力してくれます。
　出力先：`test_app/build/integration_response_data.json`


### テストを実行する

```
$ flutter drive --driver integration_test/driver.dart --target integration_test/app_test.dart --profile
```

#### 実行結果
すべてOKだとこんな感じで出力されます。

```
Running "flutter pub get" in testing_app...                         4.9s
Signing iOS app for device deployment using developer identity: "Apple Development: arlezplue.7@gmail.com (5KFW69RP5Y)"
Running pod install...                                           1,341ms
Running Xcode build...                                                  
 └─Compiling, linking and signing...                         5.9s
Xcode build done.                                           11.9s
Installing and launching...                                        14.8s
VMServiceFlutterDriver: Connecting to Flutter application at http://127.0.0.1:54091/jJdzr8Y-sYM=/
VMServiceFlutterDriver: Isolate found with number: 4018486944099739
VMServiceFlutterDriver: Isolate is paused at start.
VMServiceFlutterDriver: Attempting to resume isolate
flutter: 00:00 +0: Testing App Performance Tests Scrolling test
VMServiceFlutterDriver: Connected to Flutter application.
flutter: 00:11 +1: Testing App Performance Tests Favorites operaitons test
flutter: 00:20 +2: Testing App Performance Tests (tearDownAll)
flutter: Warning: integration_test test plugin was not detected.
flutter: 00:20 +3: All tests passed!
All tests passed.

```