Github Actionsにおけるフロントエンドテストの安定化とテストカバレッジの収集
こんにちは!研究開発エンジニアの森田(@tascript)です。今年は筋トレを頑張ったので肩と背中が去年より大きくなりました。もはやトレーニングというより「育てる」感覚に近いので、最近は肩と背中に語りかけるようにしています。今のところ特に返事はありません。
さて、さくらインターネット研究所ではプロダクト開発グループを設けており、研究成果をプロダクトを通じて社会実装し社会に役立てるという目標があります。今回はプロダクト開発グループ内で採用したフロントエンドのテスト設計の一部を紹介します。
テスト実行時間の増大とFlaky Testの発生
プロダクト開発グループでは Vitest と React 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/4Shardingによる分割はテストケースを追加しない限り一定で、オプションを付与することで、テストカバレッジのレポートを作成することもできます。例えば、以下のようなコマンドを実行することでテストケースを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-1VM2
$ pnpm exec vitest run --shard=2/4 --coverage.enabled --coverage.reportsDirectory=coverage/shard-2VM3
$ pnpm exec vitest run --shard=3/4 --coverage.enabled --covera3e.reportsDirectory=coverage/shard-3VM4
$ 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) 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)
また、マトリックス戦略の具体的な数値は、同時実行できる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-1 、shard-2 、shard-3 、shard-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@v1download-artifactsを利用して先程アップロードしたカバレッジレポート(shard-1 、shard-2 、shard-3 、shard-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.jsoncoverage/report ディレクトリには、最終的なテストカバレッジをlcov形式(lcov.info)で保存し、k1LoW/octocov-actionを利用することで、テストカバレッジを集計します。

複合アクションの活用
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のランタイムに興味がある。
入社後はフロントエンドを中心に新規プロダクトの研究、開発を担当。