⚗️

変更に影響したテストファイルだけjest実行してCIの時間を大幅に短縮させた

に公開

こんにちは!CastingONEの大沼です。

始めに

CIでデグレチェックのために単体テストを実行すると思いますが、テストファイルが増えるほど実行時間が延びてしまう問題があります。全体に影響を与えるものであればshardを使った並列実行でリアル時間を短くさせることはできますが、局所的な変更の場合は影響のないテストも実行するのは勿体なく感じます。変更したファイルに影響があるテストファイルだけテストするのが理想で色々模索していましたが、dependency-cruiserを使うことで実現することができました!
この記事ではどのようにして変更に影響したテストファイルだけを抽出し、それをテストしたかについて説明したいと思います。

dependency-cruiserを使った依存関係の抽出方法

dependency-cruiserは、ファイルの依存関係を確認するためのCLIツールです。ファイルの依存関係をグラフに表したり、循環参照を検知したりと色々できますが、今回はreachesオプションを使って特定のファイルに到達する依存関係をテキストで出力したものを使います。

具体例

イメージがつきやすいように、例としてAlertDuplicateEmailJobSeekers.tsxJobSeekerCard.tsxファイルを編集し、これらのファイルに依存したものを抽出する場合で説明したいと思います。
ファイルの依存関係が分かりやすいように先にグラフで出力して抜粋したものを以下に示します。拡大しないと文字が読みづらいですが、緑色でハイライトされているAlertDuplicateEmailJobSeekers.tsxJobSeekerCard.tsxを起点に依存のグラフが構築されていることが分かると思います。このグラフから、最終的にJobSeekerCard.test.tsxがテスト対象として抽出できると良さそうです。

それでは実際にテキストで出力してみたいと思います。コマンドは以下のようになります。reachesオプションは正規表現で設定するため、|を使って複数ファイルの指定しています。ここでは例としてファイル名のみとしていますが、他のファイルに誤ってマッチしないように実際はsrc/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.tsxみたいにフルパスで指定するようにしています。

npx depcruise src --reaches 'AlertDuplicateEmailJobSeekers.tsx|JobSeekerCard.tsx' -T text

これの実行結果が以下です。出力されたテキストから.test.tsxで検索かけると分かると思いますが、src/features/comEmail/components/JobSeekerCard/JobSeekerCard.test.tsxがヒットしたと思います。

src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.stories.tsx → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.tsx
src/features/comEmail/components/AlertDuplicateEmailJobSeekers/index.ts → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.tsx
src/features/comEmail/components/JobSeekerCard/JobSeekerCard.stories.tsx → src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx
src/features/comEmail/components/JobSeekerCard/JobSeekerCard.test.tsx → src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx
src/features/comEmail/components/JobSeekerCard/index.ts → src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx
src/features/comEmail/components/JobSeekerList/JobSeekerList.stories.tsx → src/features/comEmail/components/JobSeekerList/JobSeekerList.tsx
src/features/comEmail/components/JobSeekerList/JobSeekerList.tsx → src/features/comEmail/components/JobSeekerCard/index.ts
src/features/comEmail/components/JobSeekerList/index.ts → src/features/comEmail/components/JobSeekerList/JobSeekerList.tsx
src/features/comEmail/components/index.ts → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/index.ts
src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/EmailCommunicatedJobSeekersContainer.tsx → src/features/comEmail/components/JobSeekerCard/index.ts
src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/EmailCommunicatedJobSeekersContainer.tsx → src/features/comEmail/components/JobSeekerList/index.ts
src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/index.ts → src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/EmailCommunicatedJobSeekersContainer.tsx
src/features/comEmail/containers/EmailCommunicationAreaContainer/EmailCommunicationAreaContainer.tsx → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/index.ts
src/features/comEmail/containers/EmailCommunicationAreaContainer/index.ts → src/features/comEmail/containers/EmailCommunicationAreaContainer/EmailCommunicationAreaContainer.tsx
src/features/comEmail/views/ComEmailView.tsx → src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/index.ts
src/features/comEmail/views/ComEmailView.tsx → src/features/comEmail/containers/EmailCommunicationAreaContainer/index.ts
src/features/comEmail/views/index.ts → src/features/comEmail/views/ComEmailView.tsx

CI上で変更に影響したテストファイルを抽出してjest実行する方法

前セクションでテストファイルを抽出する方法のイメージがついたと思うので、ここからは具体的にCI上の設定を順に説明したいと思います。

PR上の変更ファイルを取得

まずはGitHub ActionsでPull Requestを出した際に、変更したファイルを取得します。これは以下のようなコードを書くことで出力できます。ポイントはチェックアウト時に過去のコミットも見れるようにfetch-depth: 0を指定しておきます。また後続の処理で扱いやすいようにファイルに出力しておきます。

PR上の変更ファイルを取得
jobs:
  dependents-test:
    if: ${{ github.event_name == 'pull_request' }}
    runs-on: ubuntu-22.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v3
        with:
          token: ${{ secrets.CASONE_BOT_TOKEN }}
          # 過去のコミット状況も見るため、0を渡して全てのコミットをfetchする
          fetch-depth: 0

      - name: Diff Files
        run: git diff --name-only $BASE_SHA...$HEAD_SHA > diff_files.txt
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}
diff_files.txtの出力例
src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.tsx
src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx

diff_files.txtに依存しているファイルリストの出力

出力した変更ファイルリストを元に、依存しているファイルリストを出力します。ファイルリストからAlertDuplicateEmailJobSeekers.tsx|JobSeekerCard.tsxのように|で結合して渡すのは$(cat ./diff_files.txt | xargs | sed 's/ /|/g')とします。

diff_files.txtに依存しているファイルリストをdependents.txtに出力
jobs:
  dependents-test:
    if: ${{ github.event_name == 'pull_request' }}
    runs-on: ubuntu-22.04
    timeout-minutes: 10
    steps:
      # 既に表示したものは省略

      # Node.jsのセットアップとパッケージのインストール
      - uses: actions/setup-node@v4
        with:
          node-version-file: 'package.json'
          cache: 'npm'
      - name: Install
        run: npm ci

      # diff_files.txtに依存しているファイルリストをdependents.txtに出力
      - name: Generate Dependents
        run: npx depcruise src --reaches $(cat ./diff_files.txt | xargs | sed 's/ /|/g') -T text > dependents.txt
dependents.txtの出力例
src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.stories.tsx → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.tsx
src/features/comEmail/components/AlertDuplicateEmailJobSeekers/index.ts → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.tsx
src/features/comEmail/components/JobSeekerCard/JobSeekerCard.stories.tsx → src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx
src/features/comEmail/components/JobSeekerCard/JobSeekerCard.test.tsx → src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx
src/features/comEmail/components/JobSeekerCard/index.ts → src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx
src/features/comEmail/components/JobSeekerList/JobSeekerList.stories.tsx → src/features/comEmail/components/JobSeekerList/JobSeekerList.tsx
src/features/comEmail/components/JobSeekerList/JobSeekerList.tsx → src/features/comEmail/components/JobSeekerCard/index.ts
src/features/comEmail/components/JobSeekerList/index.ts → src/features/comEmail/components/JobSeekerList/JobSeekerList.tsx
src/features/comEmail/components/index.ts → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/index.ts
src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/EmailCommunicatedJobSeekersContainer.tsx → src/features/comEmail/components/JobSeekerCard/index.ts
src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/EmailCommunicatedJobSeekersContainer.tsx → src/features/comEmail/components/JobSeekerList/index.ts
src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/index.ts → src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/EmailCommunicatedJobSeekersContainer.tsx
src/features/comEmail/containers/EmailCommunicationAreaContainer/EmailCommunicationAreaContainer.tsx → src/features/comEmail/components/AlertDuplicateEmailJobSeekers/index.ts
src/features/comEmail/containers/EmailCommunicationAreaContainer/index.ts → src/features/comEmail/containers/EmailCommunicationAreaContainer/EmailCommunicationAreaContainer.tsx
src/features/comEmail/views/ComEmailView.tsx → src/features/comEmail/containers/EmailCommunicatedJobSeekersContainer/index.ts
src/features/comEmail/views/ComEmailView.tsx → src/features/comEmail/containers/EmailCommunicationAreaContainer/index.ts
src/features/comEmail/views/index.ts → src/features/comEmail/views/ComEmailView.tsx

dependents.txtからテストファイルを抽出し、jestで実行できる形に調整

依存ファイルリストからテストファイルを抽出し、それをjestで実行できる形に調整します。jestで対象ファイルを指定するにはtestMatchオプションを使うと良いので、そこに使えるような形でファイル出力します。これはスクリプトを書く必要があるので、以下のようなコードを書きました。やっていることと補足説明をざっくり書くと以下の通りです。

  • diff_files.txtdependents.txtから.test.tsxのファイルパスを抽出してユニーク化
    • diff_files.txtもチェック対象としているのは、JobSeekerCard.test.tsxのように非依存のテストファイルを編集するとそれがdependents.txtには出てこないため
  • **/をprefixにつけてjest-override-test-matchファイルに出力
    • **/をprefixにつけて出力しているのはテスト実行時に上手くマッチしなかったため
scripts/generateJestOverrideTestMatch.ts
import type { ParseArgsConfig } from 'node:util'
import { parseArgs } from 'node:util'

import { promises as fsPromises } from 'fs'

const options: ParseArgsConfig['options'] = {
  inputDiffFileNamesFile: {
    type: 'string',
    default: './diff_files.txt',
  },
  inputDependentsFile: {
    type: 'string',
    default: './dependents.txt',
  },
  outputFile: {
    type: 'string',
    default: './jest-override-test-match',
  },
}

const { values } = parseArgs({ options })

const inputDiffFileNamesFilePath = values.inputDiffFileNamesFile as string
const inputDependentsFilePath = values.inputDependentsFile as string
const outputJestOverrideTestMatchFilePath = values.outputFile as string

const main = async () => {
  const diffsStr = await fsPromises.readFile(
    inputDiffFileNamesFilePath,
    'utf-8'
  )
  const diffTestFileNames = diffsStr
    .trim()
    .split('\n')
    .filter((fileName) => /\.test\.tsx?$/.test(fileName))

  const dependentsStr = await fsPromises.readFile(
    inputDependentsFilePath,
    'utf-8'
  )
  const dependentFiles = dependentsStr
    .trim()
    .split('\n')
    .map((line) => line.split(' → '))
    .flat()

  const dependentTestFileNames = dependentFiles.filter((dependent) =>
    /\.test\.tsx?$/.test(dependent)
  )

  const uniqueTestFiles = Array.from(
    new Set([...diffTestFileNames, ...dependentTestFileNames])
  )

  await fsPromises.writeFile(
    outputJestOverrideTestMatchFilePath,
    uniqueTestFiles.map((file) => '**/' + file).join('\n'),
    'utf-8'
  )
}

main()

これをCI上で実行するように書くと以下のようになります。

dependents.txtからjest-override-test-matchを出力
jobs:
  dependents-test:
    if: ${{ github.event_name == 'pull_request' }}
    runs-on: ubuntu-22.04
    timeout-minutes: 10
    steps:
      # 既に表示したものは省略

      - name: Generate Jest Override Test Match
        run: npx ts-node ./scripts/generateJestOverrideTestMatch.ts
jest-override-test-matchの出力例
**/src/features/comEmail/components/JobSeekerCard/JobSeekerCard.test.tsx

jest-override-test-matchファイルをみてテスト実行する

最後にjest-override-test-matchに出力されているテストファイルのみテスト実行されるようにjest.config.tsを編集します。全体テストでも動くように、jest-override-test-matchがある時だけそちらを参照するような書き方にしています。

jest-override-test-matchがある場合はそれを使用する
 import type { Config } from '@jest/types'
 import fs from 'fs'
 import nextJest from 'next/jest'
 import path from 'path'

+const overrideTestMatch = (() => {
+  try {
+    const testMatches = fs.readFileSync(
+      path.resolve(__dirname, './jest-override-test-match'),
+      'utf-8'
+    )
+    return testMatches.trim().split('\n')
+  } catch {
+    return undefined
+  }
+})()

 const config: Config.InitialOptions = {
+  testMatch: overrideTestMatch ?? ['**/*.test.tsx?'],
   // 他の設定は省略
 }

 const createJestConfig = nextJest({
   dir: './',
 })

 export default createJestConfig(config)

後はそのままテストを実行したらOKです。

jobs:
  dependents-test:
    if: ${{ github.event_name == 'pull_request' }}
    runs-on: ubuntu-22.04
    timeout-minutes: 10
    steps:
      # 既に表示したものは省略

      - name: Run Test
        run: npm run test

全体テストも考慮したCIの調整

以上が変更に影響したテストファイルだけを抽出してテスト実行する方法ですが、jest.config.tsの設定を見て分かる通りjest-override-test-matchファイルがあるかどうかで絞り込みテストをするかを決めているので、ワークフロー自体は共通にしておいた方が保守がしやすそうなので、そのように調整します。なお、全体テストではshardを使って並列実行しているので、その辺の設定も合わせて行います。
具体的にはjest-override-test-matchファイルを出力するjobに切り出し、そこでPull Requestの時は対象ファイルを出力してupload-artifactでアップロードしておきます。そしてファイル出力していない場合はshard-numの設定だけをして、並列実行できる形にします。そして後続のtest実行用のjobでjest-override-test-matchがある場合はダウンロードしてjestを実行します。

全体テストも考慮したテストの調整
 jobs:
+  generate-jest-override-test-match:
-  dependents-test:
-    if: ${{ github.event_name == 'pull_request' }}
     runs-on: ubuntu-22.04
     timeout-minutes: 10
+    outputs:
+      shard-num: ${{ steps.set-matrix-shard.outputs.shard-num }}

     steps:
       - uses: actions/checkout@v3
+        if: ${{ github.event_name == 'pull_request' }}
         with:
           token: ${{ secrets.CASONE_BOT_TOKEN }}
           # 過去のコミット状況も見るため、0を渡して全てのコミットをfetchする
           fetch-depth: 0

       - name: Diff Files
+        if: ${{ github.event_name == 'pull_request' }}
         run: git diff --name-only $BASE_SHA...$HEAD_SHA > diff_files.txt
         env:
           BASE_SHA: ${{ github.event.pull_request.base.sha }}
           HEAD_SHA: ${{ github.event.pull_request.head.sha }}

       # Node.jsのセットアップとパッケージのインストール
       - uses: actions/setup-node@v4
+        if: ${{ github.event_name == 'pull_request' }}
         with:
           node-version-file: 'package.json'
           cache: 'npm'
       - name: Install
+        if: ${{ github.event_name == 'pull_request' }}
         run: npm ci

       # diff_files.txtに依存しているファイルリストをdependents.txtに出力
       - name: Generate Dependents
+        if: ${{ github.event_name == 'pull_request' }}
         run: npx depcruise src --reaches $(cat ./diff_files.txt | xargs | sed 's/ /|/g') -T text > dependents.txt

       - name: Generate Jest Override Test Match
+        if: ${{ github.event_name == 'pull_request' }}
         run: npx ts-node ./scripts/generateJestOverrideTestMatch.ts

+      - name: Upload Jest Override Test Match
+        if: ${{ github.event_name == 'pull_request' }}
+        uses: actions/upload-artifact@v4
+        with:
+          name: jest-override-test-match
+          path: ./jest-override-test-match

+      - name: Set Matrix Shard
+        id: set-matrix-shard
+        run: |
+          if [ -f ./jest-override-test-match ]; then
+            echo "shard-num=[1]" >> "$GITHUB_OUTPUT"
+          else
+            echo 'shard-num=[1, 2, 3, 4]' >> "$GITHUB_OUTPUT"
+          fi

+  test:
+    runs-on: ubuntu-22.04
+    needs: generate-jest-override-test-match
+    timeout-minutes: 30
+    strategy:
+      matrix:
+        shard-num: ${{ fromJson(needs.generate-jest-override-test-match.outputs.shard-num) }}
+
+    steps:
+      # Node.jsのセットアップとパッケージのインストール
+      - uses: actions/setup-node@v4
+        with:
+          node-version-file: 'package.json'
+          cache: 'npm'
+      - name: Install
+        run: npm ci
+
+      # もしJestのtestMatchを上書きするファイルがあればダウンロードする
+      - name: Download Jest Override Test Match
+        uses: actions/download-artifact@v4
+        with:
+          name: jest-override-test-match
+          path: ./
+        continue-on-error: true
+
+      - name: Run Test
+        run: npm run test -- --shard=${{ matrix.shard-num }}/${{ strategy.job-total }}

その他微調整

これで大枠の設定は完了ですが、細かい微調整が残っているのでその辺について説明します。

影響のあるテストファイル数に応じてshardを動的に変更する

先ほどのCIの設定でjest-override-test-matchファイルがある場合はecho "shard-num=[1]" >> "$GITHUB_OUTPUT"と固定で一つのみとしていましたが、対象テストファイル数が多かった場合はかなり時間がかかってしまうので、テストファイル数に応じて動的に変更したいと思います。テストファイル数は単純にjest-override-test-matchの行数をカウントすれば良いのでwc -lコマンドでカウントしますが、空ファイルだとエラーになってしまうので別途条件分岐してそれぞれ設定します。後はこの値を元にshard-numの値を設定します。具体的には以下のようなコードになりました。対象テストファイル数が分かったので、0の場合はtest jobの方は実行されないようについでに設定しています。

 jobs:
   generate-jest-override-test-match:
     runs-on: ubuntu-22.04
     timeout-minutes: 10
     outputs:
+      num-target-files: ${{ steps.count-target-files.outputs.num-target-files }}
       shard-num: ${{ steps.set-matrix-shard.outputs.shard-num }}

     steps:
       # 変更がない部分は省略

+      - name: Count Target Files
+        if: ${{ github.event_name == 'pull_request' }}
+        id: count-target-files
+        run: |
+          if [ -f ./jest-override-test-match  ]; then
+            if [ $(cat ./jest-override-test-match | wc -c) -eq 0 ]; then
+              echo "jest-override-test-matchファイルが空でした。"
+              echo "num-target-files=0" >> $GITHUB_OUTPUT
+            else
+              numTargetFiles=$(cat ./jest-override-test-match | wc -l)
+              # 行末に改行がないため+1する
+              numTargetFiles=$(($numTargetFiles + 1))
+              echo "num-target-files=$numTargetFiles" >> $GITHUB_OUTPUT
+            fi
+          else
+            echo "jest-override-test-matchファイルが存在しませんでした。"
+          fi

       - name: Set Matrix Shard
         id: set-matrix-shard
         run: |
           if [ -f ./jest-override-test-match ]; then
+            NUM_FILES=${{ steps.count-target-files.outputs.num-target-files }}
+            COUNT=$(( $NUM_FILES / 200 + 1 ))
+            echo "shard-num=[$(seq -s, 1 $COUNT)]" >> "$GITHUB_OUTPUT"
-            echo "shard-num=[1]" >> "$GITHUB_OUTPUT"
           else
             echo 'shard-num=[1, 2, 3, 4]' >> "$GITHUB_OUTPUT"
           fi

   test:
     runs-on: ubuntu-22.04
     needs: generate-jest-override-test-match
+    # num-target-filesが未指定か0以外の数の場合のみ実行
+    if: ${{ needs.generate-jest-override-test-match.outputs.num-target-files == '' || needs.generate-jest-override-test-match.outputs.num-target-files != '0' }}
     timeout-minutes: 30
     strategy:
       matrix:
         shard-num: ${{ fromJson(needs.generate-jest-override-test-match.outputs.shard-num) }}
 
     steps:
       # 変更がないため省略

全体に影響があるファイルが変更された場合は全体テストに切り替える

例えばpackage-lock.jsonが変更された場合はライブラリが変わったので全体テストを行いたいと思います。そういったファイルがdiff_files.txtに含まれていた場合はPull Requestの時であってもjest-override-test-matchファイルの出力をスキップするようにして、全体テストに切り替わるように調整します。

package-lock.jsonが変更ファイルに含まれている場合は対象テストファイルの抽出処理をスキップする
 jobs:
   generate-jest-override-test-match:
     runs-on: ubuntu-22.04
     timeout-minutes: 10
     outputs:
       num-target-files: ${{ steps.generate-jest-override-test-match.outputs.num-target-files }}
       shard-num: ${{ steps.set-matrix-shard.outputs.shard-num }}

     steps:
       - uses: actions/checkout@v3
         if: ${{ github.event_name == 'pull_request' }}
         with:
           token: ${{ secrets.CASONE_BOT_TOKEN }}
           # 過去のコミット状況も見るため、0を渡して全てのコミットをfetchする
           fetch-depth: 0

       - name: Diff Files
         if: ${{ github.event_name == 'pull_request' }}
         run: git diff --name-only $BASE_SHA...$HEAD_SHA > diff_files.txt
         env:
           BASE_SHA: ${{ github.event.pull_request.base.sha }}
           HEAD_SHA: ${{ github.event.pull_request.head.sha }}

+      - name: Judge Dependents Generation Required
+        if: ${{ github.event_name == 'pull_request' }}
+        id: dependents-generation
+        run: |
+          if grep "^package-lock.json$" ./diff_files.txt > /dev/null; then
+            echo "package-lock.jsonを変更した場合は全てテストするためjest-override-test-matchの出力をスキップします。"
+            echo "skip=true" >> $GITHUB_OUTPUT
+          fi

       # Node.jsのセットアップとパッケージのインストール
       - uses: actions/setup-node@v4
-        if: ${{ github.event_name == 'pull_request' }}
+        if: ${{ steps.dependents-generation.outputs.skip != 'true' }}
         with:
           node-version-file: 'package.json'
           cache: 'npm'
       - name: Install
-        if: ${{ github.event_name == 'pull_request' }}
+        if: ${{ steps.dependents-generation.outputs.skip != 'true' }}
         run: npm ci

       # diff_files.txtに依存しているファイルリストをdependents.txtに出力
       - name: Generate Dependents
-        if: ${{ github.event_name == 'pull_request' }}
+        if: ${{ steps.dependents-generation.outputs.skip != 'true' }}
         run: npx depcruise src --reaches $(cat ./diff_files.txt | xargs | sed 's/ /|/g') -T text > dependents.txt

       - name: Generate Jest Override Test Match
-        if: ${{ github.event_name == 'pull_request' }}
+        if: ${{ steps.dependents-generation.outputs.skip != 'true' }}
         run: npx ts-node ./scripts/generateJestOverrideTestMatch.ts

       - name: Upload Jest Override Test Match
-        if: ${{ github.event_name == 'pull_request' }}
+        if: ${{ steps.dependents-generation.outputs.skip != 'true' }}
         uses: actions/upload-artifact@v4
         with:
           name: jest-override-test-match
           path: ./jest-override-test-match

       - name: Count Target Files
-        if: ${{ github.event_name == 'pull_request' }}
+        if: ${{ steps.dependents-generation.outputs.skip != 'true' }}
         id: count-target-files
         run: |
           if [ -f ./jest-override-test-match  ]; then
             if [ $(cat ./jest-override-test-match | wc -c) -eq 0 ]; then
               echo "jest-override-test-matchファイルが空でした。"
               echo "num-target-files=0" >> $GITHUB_OUTPUT
             else
               numTargetFiles=$(cat ./jest-override-test-match | wc -l)
               # 行末に改行がないため+1する
               numTargetFiles=$(($numTargetFiles + 1))
               echo "num-target-files=$numTargetFiles" >> $GITHUB_OUTPUT
             fi
           else
             echo "jest-override-test-matchファイルが存在しませんでした。"
           fi

       - name: Set Matrix Shard
         id: set-matrix-shard
         run: |
           if [ -f ./jest-override-test-match ]; then
             NUM_FILES=${{ steps.count-target-files.outputs.num-target-files }}
             COUNT=$(( $NUM_FILES / 200 + 1 ))
             echo "shard-num=[$(seq -s, 1 $COUNT)]" >> "$GITHUB_OUTPUT"
           else
             echo 'shard-num=[1, 2, 3, 4]' >> "$GITHUB_OUTPUT"
           fi

   test:
     # 変更がないためスキップ

アプリケーションディレクトリのみの絞り込み

今までの説明では全てルートディレクトリで行っていましたが、モノレポ運用の場合、変更ファイルリストなどのパスは全てフルパスではなく対象のアプリケーションディレクトリからの相対パスに調整する必要があります。例えば弊社では実際はapps/tenantなどにアプリケーションディレクトリがあるため、以下のように出力されています。

diff_files.txtの出力例(フルパス)
apps/tenant/src/features/comEmail/components/AlertDuplicateEmailJobSeekers/AlertDuplicateEmailJobSeekers.tsx
apps/tenant/src/features/comEmail/components/JobSeekerCard/JobSeekerCard.tsx

これを以下のようなコマンドでapp/tenant部分を取り除いて扱っていますが、こちらの設定方法を詳しく説明するとGitHub Actionsのworking-directoryの調整とかも必要になって話が長くなってしまうので割愛させていただきますが、実際はこのような調整もしています。

grep '^apps/tenant' ./diff_files.txt | sed 's|^apps/tenant||' > ./trimmed_diff_files.txt

終わりに

以上が変更に影響したテストファイルだけjest実行してCIの時間を大幅に短縮させる方法でした。今までは軽微な変更をするたびに全部テストを回していて時間がかかっており非常に億劫でしたが、これを導入したことですぐテストが終わるようになって非常にサイクルが速くなったと思います😊
この記事ではjestで説明しましたが、Vitestなど他のテストツールでもjest-override-test-matchのところだけ調整すれば動くと思いますので参考になると思います。ただbun testだと圧倒的に速くなるようなのでシュッと乗り換えられるならそちらに乗り換えてしまうという案もある気はしています。弊社はbun testへの移行にどれだけつまづくのかが不透明だったので一旦対象テストファイルを絞り込むというアプローチを取りましたが、将来的にはbun testへの乗り換えも検討しています。
軽微な変更なのに全テストするのがもどかしいと思っている方の参考になれば幸いです。

Discussion