この記事は ZOZO Advent Calendar 2023 シリーズ 9 の 16 日目の記事です。
📍はじめに
GitHub Actions を使って Jest を実行するワークフローを作成したので、その際に検証したこと(オプションなど)を備忘録も兼ねて書いていきます。
1. やりたいこと
- PR をオープンした時にテストを実行したい
- なるべく実行時間は短縮したい
2. とりあえず結論
- npm と Jest をキャッシュする
- テストが1つでも失敗したら中断する
- 実行環境の CPU を元に Jest のワーカー数を指定する
3. 試したこと
3-1. --bail
テストが何回失敗した場合にテストを中止させるかを指定します。デフォルトの回数は 1
です。
▼ 結果
こちらのオプションは実行時間には関係なさそうですが、テストが通らなかった場合にワークフローを中断したいので追加しました。
採用🙆
3-2. --forceExit
全てのテストの実行が終了した後に Jest を強制終了させるオプションです。
テストコードによって設定されたリソースが十分にクリーンアップできない場合に便利ですが、各テスト内でクリーンアップできていることが望ましいです。
▼ 結果
こちらも単体で実行時間を短縮するものではないですが、元々ローカルで使用しているテストコマンドで指定しているためそのまま採用しています。
採用🙆
3-3. --runInBand
Jest はデフォルトで並列実行しますが、このオプションを付けることで直列実行できます。
▼ 結果
CI 上では試していないのですが、ローカルで比較した所むしろ 40s 程遅くなってしまいました。
不採用🙅
3-4. --max-workers
Jest 公式ドキュメントのトラブルシューティングに記載がありました。
CPU の数を検出し Jest に渡して並行処理可能数を指定します。
GitHub Actions を使っているので、github-actions-cpu-cores を使って CPU の数を検出できます。
▼ 書き方
- 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
になりました。
▼ 結果
このオプションを指定しない場合と比較して 40s 程速くなりました🎉
採用🙆
3-5. --onlyChanged
変更があったファイルのみテストを実行します。
▼ 結果
なぜか CI 上で変更が検知されず実行されませんでした。
また、node のバージョンアップなど直接変更がない場合に発生した不具合を検知できないおそれがあるため見送りました。
不採用🙅
3-6. --changedSince
こちらも同様に変更差分に影響があるテストのみ実行します。差分を検知する対象のブランチやコミットハッシュの指定が可能です。
▼ 書き方
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: "0"
# ...
- name: Run test
run: npx jest --changedSince origin/master
▼ 結果
--onlyChanged
と同じ差分実行のリスクを考慮し見送りました。
また、テスト自体は変更した分だけ実行するため速いのですが、fetch-depth: "0"
でのチェックアウトにだいぶ時間がかかり、全体の実行時間はオプションを使わない場合と比べて約 2 倍となりました。
不採用🙅
3-7. --shard
テストの実行数を分割して並列実行します。
マトリックスを使用することで列挙した変数ごとに job を実行することができます。
参考: ジョブにマトリックスを使用する - GitHub Docs
▼ 書き方
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 分割で実行することになります。
▼ 結果
今回の場合、特段実行時間は短縮されませんでした。
また、GitHub Actions はワークフローの合計時間ではなく job 単位で無料枠が消費され、それ以上は分毎に従量課金されるためなしとしました。
不採用🙅
3-8. Jest のキャッシュ
Jest のキャッシュ機能を CI 上でも使えるように設定します。
▼ 書き方
👇キャッシュの配置先を指定し、
const jestConfig: Config.InitialOptions = {
...
cacheDirectory: 'node_modules/.cache/jest',
...
}
export default jestConfig
👇 key を元に復元して使用します。
- 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 }}-
最初は 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 を特定できていなかったようです。ここの解消にどハマりしました。
▼ 結果
--max-workers
+ Jest キャッシュ利用で 2 回目以降の実行がさらに 10 ~ 30s 速くなりました🎉
採用🙆
3-9. node_modules のキャッシュ
node_modules をキャッシュして、実行時にキャッシュが存在する場合は npm install
をスキップします。
▼ 書き方
- 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
を実行しています。
▼ 結果
--max-workers
+ Jest キャッシュにプラスして試したのですが 2 回目以降 npm install
がスキップされる分さらに 20s 程速くなりました🎉
しかし、actions/cache のドキュメントには非推奨と記載がありました。
Note It is not recommended to cache
node_modules
, as it can break across Node versions and won't work withnpm ci
.node-version
のハッシュ値をキャッシュキーに含めることで、バージョンに変更があった場合はキャッシュを読まないようにすることも考えました。
しかし、リポジトリが巨大なため何かしらの問題があった時のリスクを考慮し断念しました。
採用🙆からの不採用🙅
3-10. npm のキャッシュ
actions/setup-node を使用して npm の依存関係をキャッシュします。
参考: 依存関係のキャッシングの例 - Node.js のビルドとテスト - GitHub Docs
▼ 書き方
👇 with
に cache: "npm"
を追加します。
- 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"
▼ 結果
--max-workers
+ Jest キャッシュのみの場合と比べて 5 ~ 10s と若干ですが短縮できました。
採用🙆
4. 完成形
補足すると、master ブランチに向けた PR のタイミングで実行しているのと、リポジトリには BE のコードも存在するためワークフローの実行対象ファイルは絞り込んでいます。
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 回目以降の実行になるものの、最適化しつつテストを実行する構成にできたのではないでしょうか。
今回始めて導入したため実運用上での比較はまだできていませんが、引き続き速度改善については検討していきたいと思っています!
以下参考にさせて頂きました🌟
おしまい🎅