Github Actionsにおけるフロントエンドテストの安定化とテストカバレッジの収集

at
    Tags:
  • Testing
  • Frontend
  • プロダクト開発グループ

こんにちは!研究開発エンジニアの森田(@tascript)です。今年は筋トレを頑張ったので肩と背中が去年より大きくなりました。もはやトレーニングというより「育てる」感覚に近いので、最近は肩と背中に語りかけるようにしています。今のところ特に返事はありません。

さて、さくらインターネット研究所ではプロダクト開発グループを設けており、研究成果をプロダクトを通じて社会実装し社会に役立てるという目標があります。今回はプロダクト開発グループ内で採用したフロントエンドのテスト設計の一部を紹介します。

テスト実行時間の増大とFlaky Testの発生

プロダクト開発グループでは VitestReact Testing Library (以下、RTL)を活用してユニットテストおよびインテグレーションテストを実施しています。開発初期段階からテストを導入していて、開発が進むにつれてCI上でのテスト実行時間が増大する傾向を確認しました。また、既存のテストに依存しない新規テストケースを追加した際に、開発環境ではパスするもののCI上では失敗するといったFlaky Testが発生しました。Flaky Testの原因を追求すべく調査した結果、CIで以下のような現象が発生していました。

  • インテグレーションテストにてデータがDOMに反映されないままテストが開始されている
  • ユーザーアクションによる状態変化がDOMに反映されないままテストが開始されている
  • CI上でのテスト実行時間が開発環境の約3倍長い
     Test Files  41 passed (41)
          Tests  325 passed (325)
       Start at  06:08:03
       Duration  167.13s (transform 1.18s, setup 4.68s, collect 19.12s, tests 106.83s, environment 21.60s, prepare 4.31s)
    Github Actionsでのテスト実行時間
     Test Files  41 passed (41)
          Tests  325 passed (325)
       Start at  15:32:23
       Duration  61.28s
    開発環境でのテスト実行時間

CIにはGithub Actionsを利用していますが、Github-hosted runnersで割り当てられるコンピューティングリソースと開発マシンで使用できるリソースとの差異が大きいことによってFlaky Testが発生していると予想し、CI上のリソース不足課題に取り組みました。(Self-hosted runnersを使ってお金の力で雑にスペックアップする方法は今回見送っています)

VitestのSharding

Vitestにはテストケースを分割する Sharding と呼ばれる機能があります。例えば、以下のコマンドを実行することでテストケースを4つに分割してそのうちの1つ目(シャード)を実行することができます。

$ vitest run --shard=1/4

Shardingによる分割はテストケースを追加しない限り一定で、オプションを付与することで、テストカバレッジのレポートを作成することもできます。例えば、以下のようなコマンドを実行することでテストケースを4つに分割して実行およびテストカバレッジを収集します。

$ vitest run --shard=1/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-1
$ vitest run --shard=2/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-2
$ vitest run --shard=3/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-3
$ vitest run --shard=4/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-4

マトリックス戦略を利用したテストの並列実行

Github Actionsではワークフローのジョブ内部にマトリックスを作成することが可能です。こちらと先ほどのVitest Shardingを利用してテストを並列実行します。ワークフローは下記のように記述します。

name: Parallel Test

on:
  workflow_dispatch:
  push:
    branches:
      - main
  pull_request:
  
jobs:
  # テストを並列実行
  test:
    name: UI Tests
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shardTotal: [4]
        shardIndex: [1,2,3,4]
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
        with:
          version: 10
          run_install: false
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
      - uses: actions/cache@v4
        with:
          path: ${{ env.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-
      - name: Install Dependencies
        run: pnpm install --frozen-lockfile
      - name: Run UI Test
        run: pnpm exec vitest run --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage.enabled --coverage.reportsDirectory=./coverage/shard-${{ matrix.shardIndex }}
      - name: Upload Coverage Artifact
        uses: actions/upload-artifact@v4
        with:
          name: shard-${{ matrix.shardIndex  }}
          path: ./coverage/shard-${{ matrix.shardIndex  }}

shardTotal にてテストを4分割(4つのシャードを生成)することを宣言し、shardIndex にマトリックス戦略によって起動する各VM上で担当するシャードの番号を宣言します。これにより、各VM上では各VMにて下記のコマンドがそれぞれ実行されるため、テストを並列で実行することができます。

VM1

$ pnpm exec vitest run --shard=1/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-1

VM2

$ pnpm exec vitest run --shard=2/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-2

VM3

$ pnpm exec vitest run --shard=3/4 --coverage.enabled --covera3e.reportsDirectory=coverage/shard-3

VM4

$ pnpm exec vitest run --shard=4/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-4

各VMは以前までのテストと比較して1/4の量を処理すればよいので、以前よりもテスト実行による負荷が軽減します。結果として、Flakyテストの発生率が低下すると同時にテスト実行時間も短縮することができました。

 Test Files  41 passed (41)
      Tests  325 passed (325)
   Start at  03:00:24
   Duration  139.39s (transform 1.64s, setup 4.64s, collect 19.01s, tests 85.13s, environment 20.33s, prepare 2.28s)
直列でテストを実行した場合は139.39sで完了
 Test Files  10 passed (10)
      Tests  75 passed (75)
   Start at  09:54:09
   Duration  37.64s (transform 1.44s, setup 1.38s, collect 7.31s, tests 21.60s, environment 5.16s, prepare 228ms)
並列で実行したテストの内、最も時間を要しても37.64sで完了
Image
並列実行していることが確認できる

また、マトリックス戦略の具体的な数値は、同時実行できるjob(起動できるVMのインスタンス数)にあわせて設定するとよいでしょう。例えば、Github-hosted runnersの場合はプランによって異なるため注意が必要です。

また、各jobで生成されたカバレッジレポートは、一時的に下記のように保存されます。

coverage/
├─ shard-1/
│  └─ coverage-final.json
├─ shard-2/
│  └─ coverage-final.json
├─ shard-3/
│  └─ coverage-final.json
└─ shard-4/
   └─ coverage-final.json

今後のジョブでこれらのカバレッジレポートを再利用するため、upload-artifactを利用してアップロードします。各レポートはshard-1shard-2shard-3shard-4 として保存されます。

カバレッジの集計

生成したカバレッジレポートをマージしてテスト全体のカバレッジを集計するためのジョブを作成します。ワークフローは下記のように記述します。

name: Parallel Test

on:
  workflow_dispatch:
  push:
    branches:
      - main
  pull_request:
  
jobs:
  # テストを並列実行
  test:
    name: UI Tests
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shardTotal: [4]
        shardIndex: [1,2,3,4]
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
        with:
          version: 10
          run_install: false
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
      - uses: actions/cache@v4
        with:
          path: ${{ env.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-
      - name: Install Dependencies
        run: pnpm install --frozen-lockfile
      - name: Run UI Test
        run: pnpm exec vitest run --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage.enabled --coverage.reportsDirectory=./coverage/shard-${{ matrix.shardIndex }}
      - name: Upload Coverage Artifact
        uses: actions/upload-artifact@v4
        with:
          name: shard-${{ matrix.shardIndex  }}
          path: ./coverage/shard-${{ matrix.shardIndex  }}
  
  # レポートをマージしてカバレッジを集計する
  report:
    name: Report Test Coverage
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: pnpm/action-setup@v4
        with:
          version: 10
          run_install: false
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
      - uses: actions/cache@v4
        with:
          path: ${{ env.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-
      - name: Install Dependencies
        run: pnpm install --frozen-lockfile
      - uses: actions/download-artifact@v4
        with:
          pattern: shard-*
          path: ./coverage
      - name: Generate Reports
        uses: ./generate-report.sh
      - name: Run octocov
        uses: k1LoW/octocov-action@v1

download-artifactsを利用して先程アップロードしたカバレッジレポート(shard-1shard-2shard-3shard-4)をダウンロードします。取得したカバレッジレポートをマージするためにnycを利用します。nycはIstanbulJSのCLIで、テストカバレッジをマージおよび様々な形式のカバレッジレポートを作成することができます。VitestのBlob Reporterおよびmerge-reportsオプションを利用することで各シャードから生まれたJSONをマージしてレポートを生成することができます。しかし、他の形式のカバレッジレポートに変換する機能がないことおよびoctcovを使ってテストカバレッジを集計したいというユースケースから今回はnycを採用しました。Vitestではカバレッジプロバイダーとしてv8とistanbulのどちらかが選択できますが、Vitest v3.2.0からv8でもistanbulと同一のレポートを生成することができます。また、カバレッジの精度もistanbulと同等な上、メモリ使用量がistanbulより少ないため今回のユースケースにはv8が適切だと判断しました。最終的なレポート作成のスクリプト(generate-report.sh)は以下のように記述します。記述後はスクリプトの実行権限を付与しておきましょう。

#!/usr/bin/env bash

set -euo pipefail

DIST_DIR="coverage"
TEMP_DIR="_temp"
FLATTEN_DIR="$DIST_DIR/$TEMP_DIR"
MERGED_FILE="coverage-final.json"

rm -rf "$FLATTEN_DIR"
mkdir -p "$FLATTEN_DIR"

# レポートをcoverage/_temp配下で平坦化
find $DIST_DIR -type d -name $TEMP_DIR -prune -o -type f -name "$MERGED_FILE" -print0 |
while IFS= read -r -d '' f; do
  parent="$(basename "$(dirname "$f")")"
  output="$FLATTEN_DIR/${parent}.json"
  cp "$f" "$output"
done

# coverage/_tempに配置したレポートをマージしてcoverage/coverage-final.jsonに集約
pnpm exec nyc merge "$FLATTEN_DIR" "$DIST_DIR/$MERGED_FILE"

# coverage/coverage-final.jsonを元にlcov形式でレポートをcoverage/reportに保存
pnpm exec nyc report -t coverage \
  --reporter=lcov \
  --report-dir=coverage/report

上記のコマンドを実施後、CI上にてcoverageディレクトリ配下は以下のような構成になります。

coverage/
├─ _temp/
│  ├─ shard-1.json
│  ├─ shard-2.json
│  ├─ shard-3.json
│  └─ shard-4.json
├─ report/
│  ├─ lcov-report/
│  └─ lcov.info
├─ shard-1/
│  └─ coverage-final.json
├─ shard-2/
│  └─ coverage-final.json
├─ shard-3/
│  └─ coverage-final.json
├─ shard-4/
│  └─ coverage-final.json
└─ coverage-final.json

coverage/report ディレクトリには、最終的なテストカバレッジをlcov形式(lcov.info)で保存し、k1LoW/octocov-actionを利用することで、テストカバレッジを集計します。

Image
テストカバレッジをoctcovで集計

複合アクションの活用

Node.js環境のセットアップや、テストの並列実行など再利用性の高いものは複合アクションにまとめました。 例えば、Node.js環境セットアップの複合アクションは以下のように記述します。

# .github/actions/set-up/action.yml
runs:
  using: "composite"
  steps:
    - uses: pnpm/action-setup@v4
      with:
        version: 10
        run_install: false
    - uses: actions/setup-node@v4
      with:
        node-version: 22
    - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
      shell: bash
    - uses: actions/cache@v4
      with:
        path: ${{ env.STORE_PATH }}
        key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
        restore-keys: |
          ${{ runner.os }}-pnpm-store-
    - run: pnpm install --frozen-lockfile
      shell: bash

テストおよびカバレッジのアップロードの複合アクションは以下のように記述します。inputsを利用してジョブのマトリックス戦略に対応します。

# .github/actions/run-test-and-upload-report/action.yml
inputs:
  shardIndex: 
    description: "The index of the shard"
    required: true
  shardTotal:
    description: "The total number of shards"
    required: true
runs:
  using: "composite"
  steps:
    - name: Run UI Test
      run: pnpm exec vitest run --shard=${{ inputs.shardIndex }}/${{ inputs.shardTotal }} --coverage.enabled --coverage.reportsDirectory=./coverage/shard-${{ inputs.shardIndex }}
      shell: bash
    - name: Upload Coverage Artifact
      uses: actions/upload-artifact@v4
      with:
        name: shard-${{ inputs.shardIndex }}
        path: ./coverage/shard-${{ inputs.shardIndex }}

最終的なレポート作成のアクションは以下のように記述します。github.action_path は複合アクションが存在するパスを指します。コロケーションを意識して最終的なレポート作成のスクリプトも同一パス配下に配置します。

# .github/actions/generate-report
runs:
  using: "composite"
  steps:
    - run: ${{ github.action_path }}/script.sh
      shell: bash

これらの複合アクションを利用したワークフローは以下のように記述します。

name: Parallel Test

on:
  workflow_dispatch:
  push:
    branches:
      - main
  pull_request:
  
jobs:
  # テストを並列実行
  test:
    name: UI Tests
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shardTotal: [4]
        shardIndex: [1,2,3,4]
    steps:
      - uses: actions/checkout@v5
      - name: Setup Node.js
        uses: ./.github/actions/setup
      - name: Run Test And Upload Report
        uses: ./.github/actions/run-test-and-upload-report
        with:
          shardIndex: ${{ matrix.shardIndex }}
          shardTotal: ${{ matrix.shardTotal }}
  
  # レポートをマージしてカバレッジを集計する
  report:
    name: Report Test Coverage
    needs: [test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Setup Node.js
        uses: ./.github/actions/setup
      - uses: actions/download-artifact@v4
        with:
          pattern: shard-*
          path: ./coverage
      - name: Generate Reports
        uses: ./.github/actions/generate-report
      - name: Run octocov
        uses: k1LoW/octocov-action@v1

こうすることでコードの視認性も高くなり、類似したアクションを記載する必要がなくなります。

最後に

Flaky Testは、今回のようにCI環境だけでなく、アプリケーションの実装やテストの手法が要因となって発生します。テストコードを増やすことはプロダクトの品質を上げるために必須です。そして、テストをいかに安定して動作させるか、ということも同時に達成しなければ開発の体験はもちろん、顧客への価値提供が遅れてしまう要因になります。これからもFlaky Testとの戦いに備えて、常に考え抜く力と探求する力を鍛錬していきたいと思い筆を取った次第です。少しでもお役に立てれば幸いです。

著者

森田 亘
森田 亘
研究開発エンジニア

2024年1月入社。Web系企業にてフロントエンドからバックエンドの開発に携わり、新規開発およびレガシーなプロダクトの改善を経験。

フロントエンドのパフォーマンス改善や、様々なJavaScriptのランタイムに興味がある。

入社後はフロントエンドを中心に新規プロダクトの研究、開発を担当。