本記事は、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 }}
改善前との比較
- と同様に、分割 & 並列実行していない状態としている状態の 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分/月まで無料で、それ以上は分ごとの従量課金になるためです。
参考資料
- Jest 28: Shedding weight and improving compatibility - Jest
- ジョブにマトリックスを使用する - GitHub Docs
- GitHub Actions の課金について - GitHub Docs
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
この問題に対し、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 ヒットあり |
---|---|
参考資料
- GitHub - actions/setup-node: Set up your GitHub Actions workflow with a specific version of node.js
- cache/examples.md at main · actions/cache · GitHub
- 複合アクションを作成する - GitHub Docs
- GitHub Actionsでactions/setup-nodeだけでnode_modulesをキャッシュできるのか試してみた - DevelopersIO
まとめ
最大で 10分程度かかっていた Github Actions 上でのテスト実行を、3つの改善施策を打つことで最終的に、2〜4分程度まで短縮させることができました。今回は着手できませんでしたが、今後 ts-jest
から swc
や esbuild
への移行検討や、--changeSince
オプションの使用検討などもやっていけたらと思います。
リニューアルプロジェクトはまだまだ始まったばかりで、今後どんどんプロダクトの規模が大きくなっていき、それに伴い再びテストの実行時間も長くなっていくことでしょう。快適な開発者体験を得るために常に速度に気をつかってメンテナンスしていけたらと思います。