github actionsのjobを高速にするために取った対策

# はじめに

- github actions で CICD パイプラインを構成して運用している
  元々プロジェクト内でテストコードが少なかったのもあり最初の頃は気にする程でもなかったのだが、
  テストコードを追加し品質を上げていく過程で気づけば github actions の pull Request 時に走るジョブの実行時間が 20m 弱かかるようになっていた。
  github actions の無料枠内での実行時間上限が近づいていたこともあり、パイプラインの高速化対応を行った。

# やりたいこと

- github actions のジョブ自体の実行時間を早くしたい。
- github actions の実行時間無料枠を有効的に使いたい。

# 前提

- React(CRA) + Typescript
- テストフレームワークは Jest
- npm を使っている

# 状況

## プルリク時のジョブ実行時間抜粋

毎回大体 20 分弱かかっている
![実行時間抜粋](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/e4a56212-ffbb-e51e-cb9e-d56d739f52ab.png)


## パイプラインの実行時間内訳

- 主に時間がかかっているステップは２つ
  - `Run cd ./front-app`という step で`npm install`を行い、依存関係のあるライブラリを持ってきている
  - `Test`で`react-scripts test`を実行している。カバレッジファイルも出力する
　　![実行内訳](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/7a8b6cf5-96ea-7e2a-8a05-6ca0f5e8e074.png)


## github actions 月末での消費量

作業ブランチへの push 時に毎回 20 分もテストを実行させているので、このジョブがプロジェクト全体の利用枠を圧迫していることは明白。
![消費量](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/be9debcc-dba2-e1b4-31e4-1c8f96d2c365.png)


# 検討した対策

- github actions 上のテストを並列化する
- Jest からめちゃくちゃ早いと噂されている Vitest への移行
- `node_modules`のキャッシュ化
- ブランチとの修正差分に影響ある部分だけテストを実行する

# 対策を実行

## github actions 上のテストを並列化する

- test の実行数を分割して、複数 job での並列実行させることでパイプラインの実行時間を短くする作戦。
- 確かに早くはなるが、job 単位での分割ということは複数の`runs-on`を指定するということになる。
- github actions の実行時間無料枠の消費は job 単位(`runs-on`単位)となるため、パイプラインとしての実行時間は短縮されるが無料枠の消費時間は変わらない。
- 以上から、「`github actions の実行時間無料枠を有効的に使いたい。`」 が満たせないと判断したため、この対応は見送った。

## Jest からめちゃくちゃ早いと噂されている Vitest への移行

- 巷でテストの実行が早くなると噂されている Vitest を導入する作戦
- 実際に 388 テストファイルを Jest->Vitest へ移行して、それぞれで同じテストを使って計測してみた
  ※ どちらも並列実行数 6 で検証

### １件のテストをチョイスして単体でのスピード比較

- Enzyme+snapshot React Component テストで比較

#### Jest

![enzyme+jest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/e5672b3d-b82e-4bd8-9dac-ed8745d3d085.png)


#### Vitest

![enzyme+vitest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/0a596ed7-0b29-f69a-5d88-069c761d0d41.png)


- testing-library React Component テストで比較

#### Jest

![testing-library+jest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/d2502378-c93e-dae7-8085-6d8ef85c745e.png)


#### Vitest

![testing-library+vitest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/4d89190b-6da3-9b23-3e83-36468815816a.png)


- API 接続部分の単純な Typescript ファイルのテストで比較

#### Jest

![normal+jest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/d2118061-2cb9-e42b-2854-f41f460cc6aa.png)


#### Vitest

![normal+vitest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/57df8145-b173-d79b-6ea5-76acdefaed58.png)


### 全件分テストを実行してスピード比較

#### Jest

![jest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/87bd5dd1-6809-3ab4-0239-62041b00ec13.png)


#### Vitest

![vitest](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/e9687b51-a8a4-d35a-4a01-837b0478556f.png)


### 結果

| テスト項目      | Jest                                       | Viest                                     |
| --------------- | ------------------------------------------ | ----------------------------------------- |
| Enzyme          | 17.44s                                     | <span style="color: blue; ">10.01s</span> |
| testing-library | 14.81s                                     | <span style="color: blue; ">6.00s</span>  |
| 普通のテスト    | 7.613s                                     | <span style="color: blue; ">3.84s</span>  |
| 全テスト        | <span style="color: blue; ">277.41s</span> | 451.89s                                   |

- 少数の実行だと Vitest は Jest より早いことが確認できたが、全体で計測したところ Jest の方が早かった。なぜだ・・・
- 結論としては、テスト総実行が Vitest だと遅くなってしまう要因があり、調査も必要だと判断したため導入を見送った

## `node_modules`のキャッシュ化

- github actions の中で node_modules をキャッシュできるという記事があったので導入を行った
- `actions/setup-node`だけでキャッシュ化ができるという記事が合ったが、キャッシュが効いていなかったので`actions/cache`を併用する

### 元の github actions step

```yaml
- name: Setup
  uses: actions/setup-node@v2
  with:
    node-version: "16"
- run: |
    npm install
```

### キャッシュ化導入後 github actions step

```yaml
- name: Setup
  uses: actions/setup-node@v2
  id: setup_node_id
  with:
    node-version: "16"
- uses: actions/cache@v3
  id: node_modules_cache_id
  env:
    cache-name: cache-node-modules
  with:
    path: "**/node_modules"
    # package-lock.jsonのハッシュ値をキャッシュ用のkeyとして入れることで、依存関係が変わったらキャッシュを取り直すようになっている
    key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
- run: echo '${{ toJSON(steps.node_modules_cache_id.outputs) }}'
# キャッシュがあれば、npm install をスキップする
- if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
  run: |
    npm install
```

### 結果

- 2 回目の実行以降、`npm install`のインストール step が短縮されたことが確認できた
  ![cache-result](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/d3604837-6be2-9a2f-ca52-9a6f79499f0e.png)


## ブランチとの修正差分に影響ある部分だけテストを実行する

- そもそもプルリク時にコードの 1 部しか修正していないのに、テストの全実行をしていることが非効率なのではないかと気づいた
- よって、プルリク時のテストは作業ブランチと master との差分に影響するテストのみ実行するようにパイプラインを変える

- Jest のコマンドに`--changedSince`というオプションがある

  > Runs tests related to the changes since the provided branch or commit hash.
  > If the current branch has diverged from the given branch,
  > then only changes made locally will be tested. Behaves similarly to --onlyChanged.

  > 指定したブランチあるいはコミットハッシュからの変更に関連するテストを実行します。
  > 現在のブランチが指定したブランチから分岐している場合は、ローカルで行われた変更のみがテストされます。

  - プロダクトコードを修正した場合・・・影響するテストが実行される
  - テストコードを修正した場合・・・修正したテストが実行される

### before

- github actions step

  ```yaml
  - name: Checkout
    uses: actions/checkout@v3

  - name:
      Setup
      # 省略

  - name: Test
    run: |
      npm run test:ci
  ```

- package.json

  ```json
  {
    "scripts": {
      "test:ci": "CI=true TZ=Asia/Tokyo react-scripts test --watchAll=false"
    }
  }
  ```

- 実行時間
  ![result-before](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/f5b03953-0e4e-dc78-1654-56f93ba67423.png)

  - テスト全部実行なのでそれなりに重い

### after

- github actions step

  ```yaml
  - name: Checkout
    uses: actions/checkout@v3
    with:
      # fetch-depth: 0 を設定すると、すべてのブランチとタグのすべての履歴がフェッチされます。
      fetch-depth: "0"

  - name:
      Setup
      # 省略

  - name: Test
    run: |
      npm run test:diff:ci
  ```

- package.json

  ```json
  {
    "scripts": {
      "test:ci": "CI=true TZ=Asia/Tokyo react-scripts test --watchAll=false",
      "test:diff:ci": "CI=true TZ=Asia/Tokyo react-scripts test --watchAll=false  --changedSince origin/master"
    }
  }
  ```

  - ローカルで試すと`--changedSince master`でも動くが、github actions 上だと`--changedSince origin/master`としないとエラーが吐かれた
    - エラー内容
      ```
        ● Test suite failed to run
          fatal: ambiguous argument 'master...HEAD': unknown revision or path not in the working tree.
          Use '--' to separate paths from revisions, like this:
          'git <command> [<revision>...] -- [<file>...]'
      ```

- 実行時間
  ![result-before](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/50504a07-0f41-075c-0460-b871554f1a97.png)

  - 修正内容に影響するテストのみが実行され、実行時間が大幅に短縮された

### Vitest でブランチ間差分テストを実行したい場合

- ちなみに vitest で同じようなことをする場合は、`--changed`オプションになる

# まとめ

４つ対策として挙げたが、その中で

- `node_modules`のキャッシュ化
- ブランチとの修正差分に影響ある部分だけテストを実行する

取り入れたことで、やりたかったことの

- github actions のジョブ自体の実行時間を早くしたい。
- github actions の実行時間無料枠を有効的に使いたい。

を実現することができた。

## プルリク時のジョブ実行時間抜粋

修正量にもよるが 3m〜5m 程度でパイプラインが終わるようになった
![実行時間抜粋](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/867f6a9f-7ce9-e551-a547-71331106e5a7.png)


## パイプラインの実行時間内訳

- 主に時間がかかっているステップは２つ
- `Run cd ./front-app`という step で`npm install`を行うが、キャッシュが有効のため skip された
- `Test`で差分実行オプションを付与したため実行時間が短縮された
  ![実行内訳](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/e00afc49-27f9-2d2b-1f51-cb1c24d45030.png)


# 参考

## `node_modules`のキャッシュ化

- [GitHub Actions で actions/setup-node だけで node_modules をキャッシュできるのか試してみた](https://dev.classmethod.jp/articles/caching-dependencies-in-workflow-execution-on-github-actions/)
- [Github Actions で node 環境をセットアップ](https://zenn.dev/rena_h/scraps/6d520f2f3e6ba2)

## ブランチとの修正差分に影響ある部分だけテストを実行する

- [jest --changedsince オプション](https://jestjs.io/docs/cli#--changedsince)