Github Actions のテスト実行時間を速くするためにやったこと

本記事は、NewsPicks Advent Calendar 2022 の 12/18 公開分の記事になります。

NewsPicks Web Reader Experience Unit でフロントエンドエンジニアをしているじゆんきち(@junkisai)です。

弊チームでは、ここ1年間くらいWeb 版のNewsPicksを新しい基盤に置き換えつつ、見ためも刷新するプロジェクト(以下リニューアルプロジェクト)を進めています。

Web 版 NewsPicks は、jest でロジックの単体テスト、コンポーネントのレンダリングテスト、 a11y テストを Github Actions 上で実行しています。しかし、コードの規模が大きくなるにつれ、テストの実行時間が最大で 10 分程度かかるようになってしまい、開発体験を阻害してしまっていました。

今回は、最大 10 分程度かかっていたテストの実行時間を 2〜4 分程度まで短くした 3つの改善施策を結果とともにお届けしたいと思います。

jest cache の利用

jest はデフォルトで依存関係のツリーをキャッシュするようになっていますが、CI 上ではこのキャッシュを使用できていません。

そこで、下記のような設定を加え、jest のキャッシュを格納する場所を指定し、CI 上でキャッシュを使用できるようにしました。

// jest.config.js
module.exports = {
  ...
  cacheDirecotry: 'node_modules/.cache/jest',
  ...
}
# .github/workflows/test.yml
name: test

on: [pull_request]

jobs:
  test:
    ...
    steps:
      ...
      - name: restore jest cache
         uses: actions/cache@v3
         env:
           cache-name: cache-jest
         with:
           path: node_modules/.cache/jest
           key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
      ...

改善前との比較

同じコードにもかかわらず、CI の調子によってテスト実行時間が 1~3分ほど前後していたため、jest cache がない状態ときいた状態の CI 上のテスト実行時間をそれぞれ 10 回ずつ計測して、平均値と中央値を算出しました。

平均値 中央値
jest cache なし 430.3s 398.5s
jest cache あり 383.5s 349.5s

jest cache あり状態が改善前より平均 50s 程度短く、中央値も 50s 程度短くなっていることから、jest cache が CI のテスト実行時間を安定的に短縮していることがわかりました。

参考資料

分割 & 並列実行

jest は v28 から --shard というオプションが追加され、テスト実行を指定の数で分割することができるようになりました。このオプションを使用して、Jest 自身の CI のテスト実行時間も 20 分から 7分になったそうです。

Jest's own test suite on CI went from about 10 minutes to 3 on Ubuntu, and on Windows from 20 minutes to 7.

引用元: Jest 28: Shedding weight and improving compatibility 🫶 · Jest

また、Github Actions には Matrix Strategy という機能が備わっており、job 定義で設定した変数の組み合わせごとにジョブを1つずつ並列実行してくれます。

上記2つを組み合わせて、テストケースを 4つに分割して、CI 上で並列実行できるようにしました。

# .github/workflows/test.yml
name: test

on: [pull_request]

jobs:
  test:
    ...
    strategy:
      matrix:
        shard: [1/4, 2/4, 3/4, 4/4]
    steps:
      ...
      - run: npm run test -- --shard=${{ matrix.shard }}

改善前との比較

  1. と同様に、分割 & 並列実行していない状態としている状態の CI 上のテスト実行時間をそれぞれ 10 回ずつ計測して、平均値と中央値を算出しました。
平均値 中央値
分割 & 並列なし 383.5s 349.5s
分割 & 並列あり 114.55s 113.75s

4分割している状態の1つあたりの平均実行時間は 114.55s となり、分割していない状態よりも 150s 程度と大きく短縮できていることがわかります。

ただし、4分割している状態の1つあたりの平均実行時間は 114.55s でも、合計利用時間は 114.55 x 4 = 458.2s となり、分割していない状態よりも利用時間は伸びていることには注意が必要です。なぜかというと、Github Actions は利用時間によって料金が決まっていて、Personal だと 2,000分/月、Organization だと3,000分/月まで無料で、それ以上は分ごとの従量課金になるためです。

参考資料

node_modules をキャッシュして、 npm ci をスキップ可能にする

最後はテスト実行に限った話ではないのですが、CI 上でのテスト実行時間の短縮に寄与したため、ご紹介します。

弊プロダクトでは、npm ci をする前に cache: 'npm' つきで actions/setup-node を使用していました。

# .github/workflows/test.yml
name: test

on: [pull_request]

jobs:
  test:
    ...
    steps:
      ...
      - uses: actions/setup-node@v3
         with:
           cache: 'npm'
      - run: npm ci
      ...

しかし、Github Actions において、 cache: 'npm' を設定すると、 ~/.npm をキャッシュしてくれるのですが、node_modules をキャッシュしてくれるわけではないことがわかりました。詳細を知りたい方は、こちらの classmethod さんの記事(GitHub Actionsでactions/setup-nodeだけでnode_modulesをキャッシュできるのか試してみた - DevelopersIO)で細かな検証とともに解説していただいているので、ご一読ください。

そこで、actions/cache を使用して、 node_modules をキャッシュできるよう、以下のような yml に書き換えを行いました。キャッシュキーは package-lock.json の変更に応じて変わるようにして、キャッシュがヒットした場合は npm ci を実行しないような実装です。

# .github/workflows/test.yml
name: test

on: [pull_request]

jobs:
  test:
    ...
    steps:
      ...
      - uses: actions/cache@v3
         id: npm-cache
         with:
           path: node_modules
           key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
      - name: clean-install if cache-hit
         shell: bash
         run: |
           if [ "${{ steps.npm-cache.outputs.cache-hit }}" = "true" ]; then
             echo "skip npm clean-install"
           else
             npm ci
           fi
      ...

しかし、この記述には問題点が1つありました。それは、actions/cache のドキュメントに node_modules のキャッシュを非推奨にする理由として記載されていたもので、node のバージョンが変わった場合にも同一の node_modules のキャッシュを使用してしまい、壊れてしまう恐れがあるという点です。

Note: It is not recommended to cache node_modules, as it can break across Node versions and won't work with npm ci

引用元: actions/cache#Node - npm | Github

この問題に対し、package-lock.json に加え、.node-version ファイルのハッシュ値もキャッシュキーに含めることで解消しました。弊プロダクトでは、node のバージョン管理を nodenv で行っており、全メンバーが .node-version ファイルに記載されたバージョンを使用している体制が整っていたことで可能になった対応だと思います。

# .github/workflows/test.yml
name: test

on: [pull_request]

jobs:
  test:
    ...
    steps:
      ...
      - uses: actions/cache@v3
         id: npm-cache
         with:
           path: node_modules
           key: ${{ runner.os }}-node-${{ hashFiles('**/.node-version') }}-${{ hashFiles('**/package-lock.json') }}
      - name: clean-install if cache-hit
         shell: bash
         run: |
           if [ "${{ steps.npm-cache.outputs.cache-hit }}" = "true" ]; then
             echo "skip npm clean-install"
           else
             npm ci
           fi
      ...

改善前との比較

node_modules のキャッシュにより、1分ほどかかっていた npm ci が省略され、該当箇所が10秒程度にまで短縮されました。

cache ヒットなし cache ヒットあり
image image

参考資料

まとめ

最大で 10分程度かかっていた Github Actions 上でのテスト実行を、3つの改善施策を打つことで最終的に、2〜4分程度まで短縮させることができました。今回は着手できませんでしたが、今後 ts-jest から swcesbuild への移行検討や、--changeSince オプションの使用検討などもやっていけたらと思います。

リニューアルプロジェクトはまだまだ始まったばかりで、今後どんどんプロダクトの規模が大きくなっていき、それに伴い再びテストの実行時間も長くなっていくことでしょう。快適な開発者体験を得るために常に速度に気をつかってメンテナンスしていけたらと思います。

Page top