Flutterの3種類のソースコードテスト(Unit Test, Widget Test, Integration Test)についてのまとめ

この記事は[全部俺 Advent Calendar 2018](https://adventar.org/calendars/3702)の12日目の記事です。

# Flutterテストの種類について
ソースコードで記載するFlutterのテストには、以下のように3種類のテストがあります。
Unit Testが最も簡素なテストで、Integration Testが最も重たいテストになっています。

|   | Unit | Widget | Integration |
|:--|:--|:--|:--|
| Confidence | Low | Higher | Highest |
| Maintenance cost | Low | Higher | Highest |
| Dependencies | Few | More | Most |
| Execution speed | Quick | Slower | Slowest |

理想的なテストでは、Unit TestとWidget Testによりコードカバレッジが担保されており、Integration Testによって重要なユースケースが担保されている状態となります。
ソースコードありのテストケースだけで、ユースケースを含めたかなりの範囲をテストすることが可能です。
一番複雑なテストであるIntegration Testでは、以下のようにエミュレータ上で実際にスクロールを行うようなテストを記載する事もできます。（わかりにくいですが、人間は何も操作せず、**全自動**でスクロールが行われています。）
![scroll.gif](https://qiita-image-store.s3.amazonaws.com/0/190807/5c5c426e-27af-f88c-2f74-317b011b9c11.gif)


## Unit Test
ビジネスロジックに対するテストを記載します。
BLoCパターンで言うビジネスロジックももちろんこちらに記載しますが、それ以外でも単純な関数などはこちらに記載することになります。
まず、以下のように`test`パッケージを依存関係に含めます。

```pubspeck.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  test:
```

そして、以下のようなファイルを作成してテストを実行すると、実行結果が出力されるはずです。

```test/unit_test.dart
import 'package:test/test.dart';

void main() {
  test('my first unit test', () {
    var answer = 42;
    expect(answer, 42);
  });
}
```

```bash
~/A/flutter_apptest> flutter test test/unit_test.dart 
00:05 +1: All tests passed!      
```

テストを複数記載する場合は、`test()`を追加していけばOKです。

```test/unit_test.dart
import 'package:test_api/test_api.dart';

void main() {
  test('my first unit test', () {
    var answer = 42;
    expect(answer, 42);
  });

  test('Test order of operations: 12 + 3 * 4 = 24', () {
    const int expression = 12 + 3 * 4;
    expect(expression, equals(24));
  });
}
```

`test()`が複数記載されている場合に`flutter test`を実行すると全ケース実行されますが、特定のケースのみ実行したい場合は下記のように`--plain-name`をつけてテスト名を指定してあげればOKです。

```bash
~/A/flutter_apptest> flutter test --plain-name "Test order of operations: 12 + 3 * 4 = 24" test/unit_test.dart
00:02 +1: All tests passed!                                                                                                                                                                                                       

~/A/flutter_apptest> flutter test test/unit_test.dart 
00:02 +2: All tests passed!                            
```

## Widget Test
Widget上での挙動を含めてテストします。
Seleniumを使用してヘッドレスブラウザでのテストを記載したことのある人はイメージしやすいかもしれません。

```test/widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('my first widget test', (WidgetTester tester) async {
    // You can use keys to locate the widget you need to test
    var sliderKey = UniqueKey();
    var value = 0.0;

    // Tells the tester to build a UI based on the widget tree passed to it
    await tester.pumpWidget(
      StatefulBuilder(
        builder: (BuildContext context, StateSetter setState) {
          return MaterialApp(
            home: Material(
              child: Center(
                child: Slider(
                  key: sliderKey,
                  value: value,
                  onChanged: (double newValue) {
                    setState(() {
                      value = newValue;
                    });
                  },
                ),
              ),
            ),
          );
        },
      ),
    );
    expect(value, equals(0.0));

    // Taps on the widget found by key
    await tester.tap(find.byKey(sliderKey));

    // Verifies that the widget updated the value correctly
    expect(value, equals(0.5));
  });
}
```

こちらについてもUnit Testと同様に、`flutter test test/widget_test.dart`を実行するとテストが実行されます。

Unit Testとの違いは以下のとおりです。

- `flutter_test`パッケージを使用する
- `testWidgets`関数を使用し、第二引数で`WidgetTester`型を受け取る

```dart
await tester.pumpWidget()
```
pumpWidgetでWidgetの立ち上げを行い、以下のコードでvalueの値を確認しています。

```dart
expect(value, equals(0.0));
```
その後、以下のコードでスライダーをタップして、値が変化していることを確認しています。

```dart
await tester.tap(find.byKey(sliderKey));
```

```dart
expect(value, equals(0.5));
```

## Integration Test
日本語に直すと結合テストです。
`flutter_driver`ライブラリを用いて、エミュレータ上でテストを実行します。

Integration Testでは、以下の順番でテストを実行します。

1. 依存ライブラリ`flutter_driver`のインストール
2. `FlutterDriverExtension`機能の有効化
3. エミュレータを起動してアプリケーションを起動
4. テストコードを記載して実行

`pubspeck.yaml`に以下のように記載します。

```pubspeck.yaml
dev_dependencies:
  flutter_driver:
    sdk: flutter
```

Integration Testのディレクトリ構造は、Unit Testや　Widget Testと違い、`myapp/test/xxx_test.dart`という配置ではなく、`myapp/test_driver/xxx.dart`と`myapp/test_driver/xxx_test.dart`という配置にします。
Integration Testでは2ファイル作成する必要があるので要注意です。

以下のソースコードは、[flutter_galleryのテストコード](https://github.com/flutter/flutter/tree/master/examples/flutter_gallery/test_driver)をお借りしました。
flutter_galleryでスクロールを行う際のテストコードになります。

```test_driver/scroll_perf.dart
import 'package:flutter_driver/driver_extension.dart';
import 'package:flutter_gallery/gallery/app.dart' show GalleryApp;
import 'package:flutter/material.dart';

void main() {
  enableFlutterDriverExtension();
  runApp(const GalleryApp(testMode: true));
}

```

```test_driver/scroll_perf_test.dart
import 'dart:async';

import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;

void main() {
  group('scrolling performance test', () {
    FlutterDriver driver;

    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    tearDownAll(() async {
      if (driver != null)
        driver.close();
    });

    test('measure', () async {
      final Timeline timeline = await driver.traceAction(() async {
        await driver.tap(find.text('Material'));

        final SerializableFinder demoList = find.byValueKey('GalleryDemoList');

        // TODO(eseidel): These are very artificial scrolls, we should use better
        // https://github.com/flutter/flutter/issues/3316
        // Scroll down
        for (int i = 0; i < 5; i++) {
          await driver.scroll(demoList, 0.0, -300.0, const Duration(milliseconds: 300));
          await Future<void>.delayed(const Duration(milliseconds: 500));
        }

        // Scroll up
        for (int i = 0; i < 5; i++) {
          await driver.scroll(demoList, 0.0, 300.0, const Duration(milliseconds: 300));
          await Future<void>.delayed(const Duration(milliseconds: 500));
        }
      });

      TimelineSummary.summarize(timeline)
        ..writeSummaryToFile('home_scroll_perf', pretty: true)
        ..writeTimelineToFile('home_scroll_perf', pretty: true);
    });
  });
}
```

```bash
~/A/flutter_apptest> flutter  drive --target=test_driver/scroll_perf.dart 
Using device Android SDK built for x86.
Starting application: test_driver/scroll_perf.dart
Initializing gradle...                                       1.0s
Resolving dependencies...                                    3.7s
Installing build/app/outputs/apk/app.apk...                  6.3s
Gradle task 'assembleDebug'...                                   
Gradle task 'assembleDebug'... Done                         15.8s
Built build/app/outputs/apk/debug/app-debug.apk.
Installing build/app/outputs/apk/app.apk...                  6.1s
I/flutter ( 7309): Observatory listening on http://127.0.0.1:33656/
00:00 +0: scrolling performance test (setUpAll)
[info ] FlutterDriver: Connecting to Flutter application at http://127.0.0.1:55261/
[trace] FlutterDriver: Isolate found with number: 518742094
[trace] FlutterDriver: Isolate is paused at start.
[trace] FlutterDriver: Attempting to resume isolate
[trace] FlutterDriver: Waiting for service extension
[info ] FlutterDriver: Connected to Flutter application.
00:00 +0: scrolling performance test measure
00:14 +1: scrolling performance test (tearDownAll)
00:14 +1: All tests passed!
Stopping application instance.
```

`00:14 +1: All tests passed!`となり、Integration Testが成功していることがわかります！
テスト中のエミュレータは、以下のように動作します！

![scroll.gif](https://qiita-image-store.s3.amazonaws.com/0/190807/5c5c426e-27af-f88c-2f74-317b011b9c11.gif)

## まとめ
Flutterにおける3種類のテストについて記載しました。
余談ですが、[Dartにおけるtestライブラリ](https://github.com/dart-lang/test)にもいくつか種類があり、`test_api` → `test_core` → `test`という順序で包含関係が成り立っています。（`test`が`test_core`を内包し、`test_core`が`test_api`を内包しています。）
実は、[flutter gallery](https://github.com/flutter/flutter/tree/master/examples/flutter_gallery)では、[依存関係を極力少なくするために、`test`から`test_api`に依存先を変更する](https://github.com/flutter/flutter/issues/24005)というIssueがあり、すでにmasterに反映されています。
今回は[公式ページ](https://flutter.io/docs/testing)に則って`test_api`ではなく`test`を使用したテストを記載しましたが、今後どちらのテストライブラリを使用することが推奨されるかは変わっていくかもしれません。
実際には依存関係があるので、書き方自体はほとんど変わりません。[Unit Test](#unit-test)に記載しているソースコードの1行目を`import 'package:test/test.dart';`から`import 'package:test_api/test_api.dart';`に変更しても、問題なく動作します。
今後、どちらのライブラリが推奨されるのかも要チェックですね。

## 参考文献
[Testing Flutter apps](https://flutter.io/docs/testing)