【Flutter】入門 継続的デリバリーを参考にCI/CDを導入

# はじめに
オライリーの[入門 継続的デリバリー](https://www.oreilly.co.jp/books/9784814400690/)を読みましたので、Flutterで導入するならどのような感じになるか試してみました。

https://www.oreilly.co.jp/books/9784814400690/

ツールにはGithubActionsを使うのが簡単で良さそうだったので使用しています。

# やること
[入門 継続的デリバリー](https://www.oreilly.co.jp/books/9784814400690/)では、継続的デリバリーで実行することは以下のように書かれています。
- リンターでコードの品質を高める
- 単体テスト
- 結合テスト
- E2Eテスト
- テストカバレッジの計測
- ビルド
- パブリッシュ
- デプロイ

Flutterに置き換えると以下のようになると思います。
- リンターでコードの品質を高める
- Unitテスト、Widgetテスト、Goldenテスト（単体と結合）
- integration_test
- テストカバレッジの計測
- ビルド
- ビルドのアップロード

# 実行のタイミングについて
実行のタイミングは、パイプラインの目的によって変わります。
パイプラインとは、一連の手順のことで、具体的にCI/CDで実行するパイプラインは大きく分けると以下の二つになります。
- CIパイプライン
コードに問題がないか確認するパイプライン
- デプロイ（リリース）パイプライン
デプロイ（リリース）するパイプライン

## コードに問題がないか確認するパイプライン
こちらのパイプラインでは、リンターでの静的解析や、自動テストなどを行い、コードに問題がないかを確認します。
実行するタイミングは以下になります。
- PRが作成、更新されたとき
- PRのマージキューによるイベント
- 定期的な実行

※マージキューについては後述します。
※今回のサンプルでは定期的な実行は設定しません。

## デプロイ（リリース）パイプライン
こちらのパイプラインでは、デプロイ（リリース）するのが目的です。
ブランチ戦略により異なるかもしれないですが、実行するタイミングは以下になるかと思います。
- PRをmainにマージする時

## マージキューについて
マージキューを設定することで、PRの作成・更新時だけではなく、PRのマージ時にも自動テストなどのワークフローが実行できます。
複数人で開発をしていると、PRの作成・更新時のチェックでは問題ない場合でも、マージのタイミングによって、マージ後に問題が発生する場合があります。
それを防ぐため、マージキューという仕組みを使い、PRのマージが順番に行われるようにします。

### マージキューの動作イメージ
※マージキューを設定し、GithubActionsでマージキューのマージ時にワークフローを実行する設定をしているとします。
- PR-AとPR-Bがほぼ同時にマージを実行する
- PR-AとPR-Bはマージキューに入れられる
- `マージ先のブランチにPR-Aをマージしたコード`でワークフローを実行する
- 問題なければ、PR-Aをマージ
- `マージ先のブランチにPR-Bをマージしたコード`でワークフローを実行する
（この時点で、PR-Aはマージされているため、`マージ先のブランチ + PR-A + PR-B`のコードでワークフローを実行していることになる）
- 問題なければ、PR-Bをマージ

マージキューのマージ前のワークフローで自動テストなどを行い、問題があればマージは失敗するので、マージ前に、マージ後のコードでワークフローが成功するかを確認できる、かつ、複数のマージの競合も防げることになります。

## マージキューの設定方法
別記事としたので、ご参照ください。

https://qiita.com/MLLB/items/fc993d287eec8460ee4f

# CIパイプラインを構築
CIパイプラインでは以下の項目を実行し、
- リンターでコードの品質を高める
- フォーマッターでコードのフォーマットチェック
- Unitテスト
- Widgetテストまたは、Goldenテスト
- integration_test
- テストカバレッジの計測
- ビルド（※一旦、スキップします）

## 構築方法
構築方法はとても簡単で、プロジェクトのルートに`.github/workflows/ci.yaml`を作成し、内容を設定するだけとなります。

### リンターからテストまでを実行
テストカバレッジの計測は、追加で設定が必要なので、一旦リンターからテストまでを設定します。
```yaml:.github/workflows/ci.yaml
name: flutter_ci

on: 
  pull_request:
  merge_group:

jobs:
  CI:
    name: CI
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Create flutter environment
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.19.4'
          channel: 'stable'
          cache: true
      - name: Install packages
        run: flutter pub get
      - name: Check format
        run: dart format lib --set-exit-if-changed -o none
      - name: Analyze
        run: flutter analyze
      - name: Test
        run: flutter test
```

### テストカバレッジを計測
テストカバレッジのレポートは、`flutter test --covoerage`というコマンドでファイルとして出力できます。
以下が参考になりました。

https://zenn.dev/o_ku/articles/68a34c428c5b65#1.-%E3%82%AB%E3%83%90%E3%83%AC%E3%83%83%E3%82%B8%E8%A8%88%E6%B8%AC

上記の記事では、スクリプトを使って、出力したファイルからカバレッジのデータを抽出していますが、[Codecov](https://about.codecov.io/)という外部サービスと連携させて分析をすることもできます。
今回の記事では、[Codecov](https://about.codecov.io/)と連携させる方法としています。

CodecovはFreeプランがあるので、そちらに登録し、以下を参照して設定してください。

https://docs.codecov.com/docs/quick-start

### （Option）カバレッジをフォルダごとに設定する
こちらの章はオプションなので不要な方は読み飛ばしてください
設定をカスタマイズせずに計測すると、コード全体のテストカバレッジを計測して、計算されます。
しかし、プロジェクトによっては以下のような形も考えられるのではないかと思います。
-  Themeや、文字列定義などのファイルは対象外としたい
-  Unit Testではカバレッジは80％にしたいが、Widget Testではもう少し低くても大丈夫

プロジェクトのルートに以下のような設定ファイルを追加します。
```yaml:codecov.yaml
coverage:
  status:
    project:
      default:
        target: 80%
        threshold: 5%
      unit_tests:
        target: 80%
        threshold: 5%
      widget_tests:
        target: 70%
        threshold: 5%
ignore:
  - "lib/main.dart"
flag_management:
  default_rules:
    carryforward: true
    statuses:
      - type: project
        target: auto
        threshold: 5%
      - type: patch
        target: 70%
  individual_flags:
    - name: unit_tests
      paths: 
      - lib/domain
      carryforward: true
      statuses:
        - type: project
          target: 80%
        - type: patch
          target: 100%
    - name: widget_tests
      paths: 
      - lib/presentations
      carryforward: true
      statuses:
      - type: project
        target: 70%
      - type: patch
        target: 20%
```

いくつか設定を解説します。
- ignore
カバレッジ計測の対象外となるファイルパスを定義します。
`- "lib/main.dart"`という形で追加していくと対象のファイルは無視されます。
- individual_flags
Flagを設定することでFlagごとにカバレッジを計測できます。
今回は、`unit_tests`と`widget_tests`というフラグを設定しています。
それぞれに目標値の設定が可能で、`unit_tests`は80％、`widget_tests`は70％としています。
この辺りの数値はプロジェクトや実際に運用してみて変えていく形になると思います。

#### テストを追加
テストコードは以下となります。

```dart:sample_logic_test.dart
import "package:flutter_sample_ci/domain/sample_logic.dart";
import "package:flutter_test/flutter_test.dart";

void main() {
  final sut = SampleLogic();
  test("Unit: getSampleText should return Hello, World!", () {
    final result = sut.getSampleText();
    expect(result, "Hello, World!");
  });

  test("Unit: getGreeting should return Hello, {name}!", () {
    final result = sut.getGreeting("Flutter");
    expect(result, "Hello, Flutter!");
  });
}
```
```dart
import "package:flutter/material.dart";
import "package:flutter_sample_ci/presentations/my_app.dart";
import "package:flutter_test/flutter_test.dart";

void main() {
  testWidgets("Widget: Counter increments smoke test",
      (WidgetTester tester) async {
    // Build our app and trigger a frame.
    await tester.pumpWidget(const MyApp());

    // Verify that our counter starts at 0.
    expect(find.text("0"), findsOneWidget);
    expect(find.text("1"), findsNothing);

    // Tap the '+' icon and trigger a frame.
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    // Verify that our counter has incremented.
    expect(find.text("0"), findsNothing);
    expect(find.text("1"), findsOneWidget);
  });
}
```

詳細は[こちら](https://github.com/MLLBOrganization/flutter_sample_ci/tree/main/test)を参照してください。
ポイントとしては、テストの名前の先頭に`Unit`や`Widget`という文字を付加していることです。
こうすることで、`flutter test --name "Widget.*`と実行すると対象のテストのみ実行できます。


上記の設定ができたら、ワークフロー部分を修正します。

```diff_yaml
name: flutter_ci

on: 
  pull_request:
  merge_group:

jobs:
  CI:
    name: CI
      # 省略
-     - name: Test
-       run: flutter test
+     - name: Test
+       run: flutter test --name "Widget.*"  --coverage --coverage-path="coverage/widget_tests/lcov.info"
+     - name: Upload coverage reports to Codecov
+       uses: codecov/codecov-action@v4.0.1
+       with:
+         token: ${{ secrets.CODECOV_TOKEN }}
+         directory: coverage/widget_tests
+         flags: widget_tests
+     - name: Test
+       run: flutter test --name "Unit.*"  --coverage --coverage-path="coverage/unit_tests/lcov.info"
+     - name: Upload coverage reports to Codecov
+       uses: codecov/codecov-action@v4.0.1
+       with:
+         token: ${{ secrets.CODECOV_TOKEN }}
+         directory: coverage/unit_tests
+         flags: unit_tests
```

## E2Eテスト
E2Eテストの実行は[こちら](https://www.geekco.fr/posts/how-to-add-flutter-integration-test-in-a-ci-with-github-action)の記事を参考に以下のようにしました。

```diff_yaml
name: flutter_ci

on: 
  pull_request:
  merge_group:

jobs:
  CI:
    name: CI
      # 省略
     - name: Test
       run: flutter test --name "Unit.*"  --coverage --coverage-path="coverage/unit_tests/lcov.info"
     - name: Upload coverage reports to Codecov
       uses: codecov/codecov-action@v4.0.1
       with:
         token: ${{ secrets.CODECOV_TOKEN }}
         directory: coverage/unit_tests
         flags: unit_tests
+    - name: Run only on merge_group
+      if: github.event_name == 'merge_group'
+      #run: echo "This step runs only on merge_group events."
+      uses: reactivecircus/android-emulator-runner@v2
+      with:
+        api-level: 29
+        arch: x86_64
+        profile: Nexus 6
+        script: flutter test integration_test --verbose
```

サンプルのため、Androidのみ設定してますが、iOSも必要だと思います。

# リリースパイプラインを構築
TODO（後日更新）