GitHub Actionsを使ってPR時にJestをいい感じに実行したい

:::note info
この記事は [ZOZO Advent Calendar 2023](https://qiita.com/advent-calendar/2023/zozo) シリーズ 9 の 16 日目の記事です。
:::

# 📍はじめに

GitHub Actions を使って Jest を実行するワークフローを作成したので、その際に検証したこと（オプションなど）を備忘録も兼ねて書いていきます。

# 1. やりたいこと

* PR をオープンした時にテストを実行したい
* なるべく実行時間は短縮したい

# 2. とりあえず結論

* npm と Jest をキャッシュする
* テストが１つでも失敗したら中断する
* 実行環境の CPU を元に Jest のワーカー数を指定する

# 3. 試したこと

## 3-1. `--bail`

テストが何回失敗した場合にテストを中止させるかを指定します。デフォルトの回数は `1` です。

<br/>

**▼ 結果**

こちらのオプションは実行時間には関係なさそうですが、テストが通らなかった場合にワークフローを中断したいので追加しました。

:::note info
採用🙆
:::

## 3-2. `--forceExit`

全てのテストの実行が終了した後に Jest を強制終了させるオプションです。
テストコードによって設定されたリソースが十分にクリーンアップできない場合に便利ですが、各テスト内でクリーンアップできていることが望ましいです。

<br/>

**▼ 結果**

こちらも単体で実行時間を短縮するものではないですが、元々ローカルで使用しているテストコマンドで指定しているためそのまま採用しています。

:::note info
採用🙆
:::

## 3-3. `--runInBand`

Jest はデフォルトで並列実行しますが、このオプションを付けることで直列実行できます。

<br/>

**▼ 結果**

CI 上では試していないのですが、ローカルで比較した所むしろ 40s 程遅くなってしまいました。

:::note alert
不採用🙅
:::

## 3-4. `--max-workers`

[Jest 公式ドキュメントのトラブルシューティング](https://jestjs.io/docs/troubleshooting#tests-are-extremely-slow-on-docker-andor-continuous-integration-ci-server)に記載がありました。
CPU の数を検出し Jest に渡して並行処理可能数を指定します。
GitHub Actions を使っているので、[github-actions-cpu-cores](https://github.com/SimenB/github-actions-cpu-cores) を使って CPU の数を検出できます。

<br/>

**▼ 書き方**

```yml
- name: Get number of CPU cores
  id: cpu-cores
  uses: SimenB/github-actions-cpu-cores@v2
  
- name: Run test
  run: yarn jest --max-workers ${{ steps.cpu-cores.outputs.count }}
```
CI 上で実行すると `${{ steps.cpu-cores.outputs.count }}` は `2` になりました。

<br/>

**▼ 結果**

このオプションを指定しない場合と比較して 40s 程速くなりました🎉

:::note info
採用🙆
:::

## 3-5. `--onlyChanged`

変更があったファイルのみテストを実行します。

<br/>

**▼ 結果**

なぜか CI 上で変更が検知されず実行されませんでした。
また、node のバージョンアップなど直接変更がない場合に発生した不具合を検知できないおそれがあるため見送りました。

:::note alert
不採用🙅
:::

## 3-6. `--changedSince`

こちらも同様に変更差分に影響があるテストのみ実行します。差分を検知する対象のブランチやコミットハッシュの指定が可能です。

<br/>

**▼ 書き方**

```yml
- name: Checkout
  uses: actions/checkout@v3
  with:
    fetch-depth: "0"
# ...
- name: Run test
  run: npx jest --changedSince origin/master
```

<br/>

**▼ 結果**

`--onlyChanged` と同じ差分実行のリスクを考慮し見送りました。
また、テスト自体は変更した分だけ実行するため速いのですが、`fetch-depth: "0"` でのチェックアウトにだいぶ時間がかかり、全体の実行時間はオプションを使わない場合と比べて約 2 倍となりました。

:::note alert
不採用🙅
:::

## 3-7. `--shard`

テストの実行数を分割して並列実行します。
マトリックスを使用することで列挙した変数ごとに job を実行することができます。

参考: [ジョブにマトリックスを使用する - GitHub Docs](https://docs.github.com/ja/actions/using-jobs/using-a-matrix-for-your-jobs)

<br/>

**▼ 書き方**

```yml
jobs:
  test:
    runs-on: ubuntu-20.04

    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    # ...
    steps:
        # ...
      - name: Run test
        run: npx jest --shard=${{ matrix.shard }}        
```

上記の場合、job を 4 分割で実行することになります。

<br/>

**▼ 結果**

今回の場合、特段実行時間は短縮されませんでした。
また、GitHub Actions はワークフローの合計時間ではなく job 単位で無料枠が消費され、それ以上は分毎に従量課金されるためなしとしました。

:::note alert
不採用🙅
:::

## 3-8. Jest のキャッシュ

Jest のキャッシュ機能を CI 上でも使えるように設定します。

<br/>

**▼ 書き方**

👇キャッシュの配置先を指定し、

```ts:jest.config.ts
const jestConfig: Config.InitialOptions = {
    ...
    cacheDirectory: 'node_modules/.cache/jest',
    ...
}
export default jestConfig
```

👇 key を元に復元して使用します。

```yml
- name: Cache Jest
  uses: actions/cache@v3
  env:
    cache-name: cache-jest
  with:
    path: app-frontend/assets/node_modules/.cache/jest
    key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('app-frontend/assets/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-build-${{ env.cache-name }}-
```

:::note warn
最初は path を `node_modules/.cache/jest` で指定していたのですが、キャッシュの保存時に「いや、そんなパスないが😅（意訳）」と注意されました。

```
Warning: Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.
```

今回導入したリポジトリは複数パッケージが存在しているため、 うまく path を特定できていなかったようです。ここの解消にどハマりしました。
:::

<br/>

**▼ 結果**

`--max-workers` + Jest キャッシュ利用で 2 回目以降の実行がさらに 10 ~ 30s 速くなりました🎉

:::note info
採用🙆
:::

## 3-9. node_modules のキャッシュ

node_modules をキャッシュして、実行時にキャッシュが存在する場合は `npm install` をスキップします。

<br/>

**▼ 書き方**

```yml
- name: Cache Node Modules
  uses: actions/cache@v3
  id: node_modules_cache_id
  env:
    cache-name: cache-node-modules
  with:
    path: "app-frontend/assets/node_modules"
    key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}

- name: Install Dependencies
  if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
  run: |
    npm install
```

`if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}` の部分でキャッシュが存在するかを判断し、ない場合に `npm install` を実行しています。

<br/>

**▼ 結果**

`--max-workers` + Jest キャッシュにプラスして試したのですが 2 回目以降 `npm install` がスキップされる分さらに 20s 程速くなりました🎉

しかし、[actions/cache](https://github.com/actions/cache/blob/main/examples.md#node---npm) のドキュメントには**非推奨**と記載がありました。
> Note It is not recommended to cache `node_modules`, as it can break across Node versions and won't work with `npm ci`

`.node-version` のハッシュ値をキャッシュキーに含めることで、バージョンに変更があった場合はキャッシュを読まないようにすることも考えました。
しかし、リポジトリが巨大なため何かしらの問題があった時のリスクを考慮し断念しました。

:::note alert
採用🙆からの不採用🙅
:::

## 3-10. npm のキャッシュ

[actions/setup-node](https://github.com/actions/setup-node) を使用して npm の依存関係をキャッシュします。

参考: [依存関係のキャッシングの例 - Node.js のビルドとテスト - GitHub Docs](https://docs.github.com/ja/actions/automating-builds-and-tests/building-and-testing-nodejs#example-caching-dependencies)

<br/>

**▼ 書き方**

👇 `with` に `cache: "npm"` を追加します。

```yml
- name: Use Node.js
  uses: actions/setup-node@v3
  with:
    node-version-file: "app-frontend/assets/.node-version"
    cache: "npm"
    cache-dependency-path: "app-frontend/assets/package-lock.json"
```

<br/>

**▼ 結果**

`--max-workers` + Jest キャッシュのみの場合と比べて 5 ~ 10s と若干ですが短縮できました。

:::note info
採用🙆
:::

# 4. 完成形

補足すると、master ブランチに向けた PR のタイミングで実行しているのと、リポジトリには BE のコードも存在するためワークフローの実行対象ファイルは絞り込んでいます。

```yml
name: FrontendTestAssets

on:
  workflow_dispatch:

  pull_request:
    branches:
      - master
    paths:
      - "app-frontend/assets/.node-version"
      - "app-frontend/assets/src/**/*.ts"
      - "app-frontend/assets/src/**/*.tsx"
      - "app-frontend/assets/package.json"
      - "app-frontend/assets/package-lock.json"
    types: [opened, reopened, synchronize, ready_for_review]

jobs:
  test:
    if: ${{ github.event.pull_request.draft == false }}
    runs-on: ubuntu-20.04

    defaults:
      run:
        working-directory: ./app-frontend/assets

    steps:
      - name: Checkout Commit
        uses: actions/checkout@v3

      - name: Use Node.js
        uses: actions/setup-node@v3
        with:
          node-version-file: "app-frontend/assets/.node-version"
          cache: "npm"
          cache-dependency-path: "app-frontend/assets/package-lock.json"

      - name: Install Dependencies
        run: npm ci

      - name: Cache Jest
        uses: actions/cache@v3
        env:
          cache-name: cache-jest
        with:
          path: app-frontend/assets/node_modules/.cache/jest
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('app-frontend/assets/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-

      - name: Get number of CPU cores
        uses: SimenB/github-actions-cpu-cores@v1
        id: cpu-cores

      - name: Run test SP
        run: npx cross-env DEVICE=sp jest --bail --forceExit --max-workers ${{ steps.cpu-cores.outputs.count }}

      - name: Run test PC
        run: npx cross-env DEVICE=pc jest --bail --forceExit --max-workers ${{ steps.cpu-cores.outputs.count }}
```

# 📍おわりに

PR をトリガーとしたワークフローの実行のためキャッシュの恩恵を受けられるのは 2 回目以降の実行になるものの、最適化しつつテストを実行する構成にできたのではないでしょうか。
今回始めて導入したため実運用上での比較はまだできていませんが、引き続き速度改善については検討していきたいと思っています！

以下参考にさせて頂きました🌟

https://jestjs.io/docs/cli

https://engineering.meetsmore.com/entry/2021/01/21/134930

https://tech.uzabase.com/entry/2022/12/18/143642

https://dev.classmethod.jp/articles/caching-dependencies-in-workflow-execution-on-github-actions/

https://qiita.com/itouoti/items/37e0420b232a1c694288

https://qiita.com/yamatai12/items/52c7c01c034bc57959c2

https://zenn.dev/odan/articles/886bb04688a039

おしまい🎅