Flutterテストとgithubワークフロー設定の基本

# 内容
github ワークフローで実現したいこと
- code formatできてるかをチェック
- flutter analyzeで静的分析
- dependabotでpackage updateの自動化
- test
    - unit test
    - widget test
    - golden test
    - code coverageチェック

# まず、サンプルapp作成
templateを使ってアプリを作成するツールを使います。

https://github.com/verygoodopensource/very_good_cli

基本的なものが整備されるので、便利です〜
- Blocで実現した定番のcounterアプリ
- Unit, widget testコード
- github workflow

#### アプリを作成
```shell
dart pub global activate very_good_cli
very_good create learn_flutter_test
very_good packages get
```

# テストコードを実行
サンプルのテストコードがあるので、早速実行して見ます
#### commandで実行する方法
```
fvm flutter test --coverage
# coverage/lcov.infoにcoverageが生成される
```

生成されたcoverageファイルををhtmlに変換して読めるようにする
```
brew install lcov
genhtml coverage/lcov.info -o coverage/html
```
coverage htmlのイメージ
![スクリーンショット 2022-12-10 13.45.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114912/757f2ee7-99f7-ea24-9ec5-0e24af7d06bf.png)

![スクリーンショット 2022-12-10 14.51.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114912/5cda0d55-af0f-8f34-c1fb-a1b0e73d9547.png)


#### Android studioで実行する方法
testフォルダを右クリックして、メニュー`Run 'tests in test with Coverage'`をクリックすると、
IDE上の左側のfile browserでファイル毎のCoverageが確認できる
<img width="500" alt="mojikyo45_640-2.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114912/71bf93cc-ca33-4eea-2bd1-2428a363af80.png">

## Gloden test
#### どんなテスト？ 
UIの期待画像を事前に生成しておいて、テストで期待画像を比較して、差分があるかを確認するUI testの一種です。

package `golden_toolkit`を使うので、以下のようにdependencyを指定します
```yaml
dev_dependencies:
  golden_toolkit: ^0.14.0
```

ファイル`test/flutter_test_config.dart`を作成して、以下のようなコードにする
- これはテストが実行する前に実行するファイル

```dart
import 'dart:async';

import 'package:golden_toolkit/golden_toolkit.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  await loadAppFonts(); // localしないと、screenshotで文字が表示できない問題が発生します
  await testMain();
}
```

#### テストコード書く
test/counter_page_golen_test.dart
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:learn_flutter_test/app/app.dart';

void main() {
  testGoldens('CounterPage init', (tester) async {
    await tester.pumpWidgetBuilder(const App());

    await screenMatchesGolden(tester, 'CounterPage_init');
  });
  
  testGoldens('CounterPage click', (tester) async {
    await tester.pumpWidgetBuilder(const App());
    await tester.tap(find.byIcon(Icons.add));
    await screenMatchesGolden(tester, 'CounterPage_click-1-times');
    await tester.tap(find.byIcon(Icons.add));
    await screenMatchesGolden(tester, 'CounterPage_click-2-times');
  });
}

```

#### glodenファイル（期待してるUI画像）を生成
```shell
fvm flutter test --update-goldens
```

`test/goldens`フォルダにファイルが作成されます
![スクリーンショット 2022-12-10 13.55.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114912/b062fc04-bb04-5e36-7abe-f9b765d49d21.png)

#### testを実行
```shell
 fvm flutter test test/counter_page_golen_test.dart 
```

- 成功の場合
![スクリーンショット 2022-12-10 13.57.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114912/171b842a-604e-b793-86da-d837b6c1031a.png)

- 失敗の場合
失敗させるために、コードを修正します
lib/counter/cubit/counter_cubit.dartを修正して、変化の単位を10にする
```
  void increment() => emit(state + 1); //修正前
  void increment() => emit(state + 10); //修正後
```

テスト結果出力の抜粋です。結果から見ると、click１回、2回した後の画像が期待結果と異なってます。
```                  
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown while running async test code:
Golden "goldens/CounterPage_click-1-times.png": Pixel test failed, 0.90% diff detected.
....

The following assertion was thrown while running async test code:
Golden "goldens/CounterPage_click-2-times.png": Pixel test failed, 0.92% diff detected.
....

00:05 +1 -1: Some tests failed. 
```

実際の画像の差分はfailuresフォルダに出力した画像から確認できます
- _testImage.png　　⇨ 実際出力した画像
- _masterImage.png　⇨ 期待画像
- _maskedDiff.png　　⇨ 差分の画像
- _isolatedDiff.png　　⇨ 差分の箇所だけの画像
![failure.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114912/7253584e-075c-4e22-37eb-1cab08d14f3d.gif)

#### responstive UIをテストしたい場合
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:learn_flutter_test/app/app.dart';

void main() {
  testGoldens('CounterPage click', (tester) async {
    final builder = DeviceBuilder()
      ..overrideDevicesForAllScenarios(devices: [
        Device.phone,
        Device.iphone11,
        const Device(name: 'ipad pro', size: Size(1366, 1024))
      ]) //　テストしたい端末かサイズを指定
      ..addScenario(
        widget: const App(),
        name: 'init',
      )
      ..addScenario(
        widget: const App(),
        name: 'tap add button 1 times',
        onCreate: (scenarioWidgetKey) async {
          final finder = find.descendant(
            of: find.byKey(scenarioWidgetKey),
            matching: find.byIcon(Icons.add),
          );
          await tester.tap(finder);
        },
      );
    await tester.pumpDeviceBuilder(builder);

    await screenMatchesGolden(tester, 'counter-page');
  });
}
```

生成されたgoldenファイルはこんな感じになります。
![counter-page.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114912/3f7740db-acd4-0b2e-cc40-d7a7fa8693e6.png)

# Integration test
#### pubspec.yamlに`integration_test` packageを指定する
```yaml
dev_dependencies:
  integration_test:
    sdk: flutter
```

#### テストdriverを指定する
test_driver/integration_test.dart
```dart
import 'package:integration_test/integration_test_driver_extended.dart';

Future<void> main() async => integrationDriver();

```

#### テストコードを書く
integration_test/app_test.dart

```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:learn_flutter_test/main_production.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('counter', (tester) async {
    debugPrint('---test start--');
    app.main();
    await tester.pumpAndSettle();
    final counter = find.byKey(const Key('count')).evaluate().single.widget as Text;
    expect(counter.data, '0');

    final addButton = find.byIcon(Icons.add);
    await tester.tap(addButton);
    await tester.pumpAndSettle();
    expect(counter.data, '1');

    await tester.tap(addButton);
    await tester.pumpAndSettle();
    expect(counter.data, '2');
    debugPrint('---test end--');
  });
}
```

#### テストを実行
まずは[chromedriver](https://chromedriver.chromium.org/downloads)をinstallしておきます。

いろんなtarget deviceでテストを実行する
```bash
chromedriver --port=4444
# chromeで実行する
fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d chrome
# no-headlessで実行する
fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d web-server --no-headless
# headlessで実行する
fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d web-server --headless
```

Target deviceにより挙動が少し違います。
- web-server：　
    - debugPrintでlogを出力できない
- chrome: 
    - debugPrintでlogを出力できる
    - 一部のexpectが失敗しても、最終結果が`All tests passed.`になる問題がある。このコメントを参考して、対応できる
        - https://github.com/flutter/flutter/issues/34499#issuecomment-1223302298


# github workflowの設定
注意点： Gloden testの画像生成はOSにより差分があるので、worflow上のOSがlocalと同じにしないと、テストが失敗する可能性があります。

```yaml
name: learn_flutter_test

on: [pull_request, push]

jobs:
  build:
    uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
    with:
      flutter_channel: stable
      flutter_version: 3.3.8
      runs_on: macos-latest # localのOSと同じ

  integration-test:
    runs-on: macos-latest
    steps:
      - name: 📚 Git Checkout
        uses: actions/checkout@v3
      - name: Setup flutter
        uses: subosito/flutter-action@v2
        with:
          channel: stable
          flutter-version: 3.3.8
          cache: true
          cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }}
      - name: Run Test
        run: |
          chromedriver --port=4444 &
          flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d web-server
```

実行結果はここにあります。
https://github.com/qiuyin/learn_flutter_test/actions/runs/3705354235

# 出来上がったコード
https://github.com/qiuyin/learn_flutter_test