VISASQ Dev Blog

ビザスク開発ブログ

Devin とGitHub Actionsで実現する継続的カバレッジ改善の取り組み

はじめに

クライアント開発チームの安野です。

クライアント開発チームでは、クライアントポータルという to B 向けのサービス開発を担当しており、私はそこでフロントエンド・バックエンドの開発に携わっています。

クライアントポータルの内容はこちらからも確認できるので、ご興味があれば是非ご一読いただけますと幸いです!

square.visasq.com

そんなクライアント開発チームのプロジェクトでは、テストカバレッジに関して主に以下の課題を抱えていました:

1. コードレビューの課題

  • テスト漏れの発見: プルリクエストで新たに実装されたロジックのテストに漏れがないか、レビュー時に判断するのが大変
  • 分岐カバレッジの確認: 「この条件分岐は考慮されているか」「このエッジケースはテストされているか」といった確認が属人的で非効率
  • レビュー負担の増大: テストの網羅性を確認するために、実装コードとテストコードの両方を詳細に読む必要がある

2. 既存コードの品質課題

  • テスト不足のレガシーコード: プロジェクトの初期段階や急ぎの実装で、テストが十分に書かれていないコードが存在する
  • 品質の可視化不足: どのコードがテストされておらず、優先的に改善すべきかが明確でない
  • 継続的な改善の仕組みがない: テストカバレッジを向上させるための体系的なプロセスが確立されていない

解決策:カバレッジの継続的可視化と改善

これらの課題を解決するために、私たちはGitHub Actionsを活用したカバレッジの継続的可視化と改善サイクルを導入しました。

テストカバレッジを常に可視化し、開発プロセスに組み込むことで、コードの品質を定量的に評価し、開発効率を向上させることができます。 本記事では、クライアント開発チームで実践しているカバレッジ可視化と改善の取り組みについて紹介します。

具体的には、以下の2つのワークフローを実装しました:

1. PR Coverage Workflow

PRが作成されると、変更されたファイルのカバレッジレポートを自動生成し、PRにコメントとして表示します。 これにより、開発者とレビュワーは自分の変更がテストでどの程度カバーされているかを即座に確認できます。

主な機能:

  • PRで変更されたファイルのみを対象にカバレッジを計測
  • ステートメント、ブランチ、関数、ラインの各カバレッジを表示
  • カバーされていない行(Uncovered Lines)を特定し、コードへのリンク付きで表示
  • 開発者が自分の変更のテストカバレッジを即座に確認可能

コメント例 :

  • 変更されたファイルの各指標とUncovered Linesを表示します

2. Master Coverage Workflow

毎週月曜日に実行され、masterブランチ全体のカバレッジを計測します。 カバレッジが80%未満のファイルを特定し、それぞれにGitHub Issueを作成します。 これにより、テストカバレッジが不足している箇所を継続的に特定し、改善することができます。

主な機能:

  • 週次スケジュールで実行(毎週月曜日)
  • 全ファイルのカバレッジを計測
  • カバレッジが80%未満のファイルを特定
  • 特定されたファイルごとにGitHub Issueを作成(最大10件/実行)
  • Slackにカバレッジサマリーを通知

作成されるissueの例 :

  • 各指標とUncovered Linesに加え、そのままDevinに投げられるようにプロンプトも載っています

通知されるSlackの例 :

  • 全体の指標を表示します
  • 詳細レポートでは全ファイルの指標が確認できます

実装

カバレッジの継続的可視化と改善サイクルを実装するための具体的な手順を紹介します。

1. 共通の準備と設定

1.1 必要なツールのインストール

まず、テストカバレッジを計測するためのツールをインストールします。Vitestと任意のプロバイダーを使用します。

Coverage | Guide | Vitest

# Vitestをインストール
npm install -D vitest


# Istanbulをインストール
npm i -D @vitest/coverage-istanbul


# もしくはv8をインストール
npm i -D @vitest/coverage-v8

1.2 テスト設定の構成

vitest.config.tsファイルを作成し、カバレッジレポートの設定を行います。

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
         // プロバイダーの設定
         provider: "istanbul",
         // レポーターの設定(カバレッジの出力形式を定める。json-summaryとjsonによって後続のパーセンテージやUncovered Lineを取得する)
         reporter: [
           "text",
           "text-summray",
           "json-summary",
           "json",
         ],
         // カバレッジ対象から除外するファイルとディレクトリ
         exclude: [
           "**/node_modules/**",
           ...
         ],
    },
  },
})

1.3 package.jsonにテストコマンドを追加

package.jsonファイルにカバレッジレポートを生成するためのコマンドを追加します。

{
  "scripts": {
    "test": "vitest run",
    "test:coverage": "vitest run --coverage.enabled true",
  }
}

2. PR Coverage Workflow の実装

PRが作成されたときにカバレッジレポートを生成し、PRにコメントするためのGitHub Actionsワークフローを実装します。

  1. PRの変更ファイル特定: PRで変更されたファイルをgit diffコマンドで特定
  2. テスト実行: 変更されたファイルに関連するテストを実行
  3. カバレッジ計測: 変更ファイルのカバレッジを計測し、JSON形式で保存
  4. レポート生成: カバレッジデータを解析しレポートを生成
  5. PRコメント: 生成したレポートをPRにコメントとして投稿

実際のコードを載せると長くなり過ぎてしまうため、一部割愛します...

# .yml

name: "PR Coverage Report"
on:
  pull_request:

permissions:
  contents: read
  pull-requests: write

jobs:
  pr-coverage:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Node
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - name: Install Dependencies
        run: cd app && npm install

      - name: Get Changed Files
        id: changed-files
        run: |
          ...
     

      - name: Run Tests with Coverage for Changed Files
        if: steps.changed-files.outputs.has_changed_files == 'true'
        run: |
          # 変更されたファイルのリストを読み込む
          CHANGED_FILES=$(cat ../changed_files.txt)

          ...

          # テスト実行
          echo "Running tests with coverage for changed files"
          eval "npm run test:coverage -- $COVERAGE_ARGS --run"

          # カバレッジファイルが生成されたか確認
          if [ -f "coverage/coverage-summary.json" ]; then
            # 結果を保存
            cp coverage/coverage-summary.json "../coverage_results/coverage-summary.json"
            cp coverage/coverage-final.json "../coverage_results/coverage-final.json"
            echo "Coverage report saved"
            echo "no_coverage=false" >> $GITHUB_ENV


      - name: Generate Coverage Report
        id: report
        run: |
         ...

          # レポートの開始部分
          echo "report<<EOF" >> $GITHUB_OUTPUT
          echo "<h3>Changed Files</h3>" >> $GITHUB_OUTPUT
          echo "<table>" >> $GITHUB_OUTPUT
          echo "<thead>" >> $GITHUB_OUTPUT
          echo "<tr>" >> $GITHUB_OUTPUT
          echo "<th align=\"left\">File</th>" >> $GITHUB_OUTPUT
          echo "<th align=\"right\">Stmts</th>" >> $GITHUB_OUTPUT
          echo "<th align=\"right\">Branches</th>" >> $GITHUB_OUTPUT
          echo "<th align=\"right\">Functions</th>" >> $GITHUB_OUTPUT
          echo "<th align=\"right\">Lines</th>" >> $GITHUB_OUTPUT
          echo "<th align=\"left\">Uncovered Lines</th>" >> $GITHUB_OUTPUT
          echo "</tr>" >> $GITHUB_OUTPUT
          echo "</thead>" >> $GITHUB_OUTPUT
          echo "<tbody>" >> $GITHUB_OUTPUT

         ...
            # 各ファイルのカバレッジ情報を抽出
            for FILE in $SORTED_FILES; do
              # 既に処理済みのファイルはスキップ
              if [ "${PROCESSED_FILES[$FILE]}" = "1" ]; then
                continue
              fi
              
              # テストファイルかチェック
              if [[ "$FILE" == *.test.ts ]]; then
                # テストファイル自体はカバレッジレポートに含めない
                continue
              fi

              # ファイルの絶対パスを取得
              FULL_PATH="$(pwd)/app/$FILE"

              # jqを使ってカバレッジ情報を抽出
              FILE_COVERAGE=$(echo "$COVERAGE_SUMMARY" | jq -r ".[\"$FULL_PATH\"]")

              if [ "$FILE_COVERAGE" != "null" ]; then
                # ファイルへのリンクを作成(特殊文字をURLエンコード)
                # [id].ts -> %5Bid%5D.ts
                ENCODED_FILE=$(echo "app/$FILE" | sed 's/\[/%5B/g' | sed 's/\]/%5D/g')
                FILE_URL="$REPO_URL/blob/$COMMIT_SHA/$ENCODED_FILE"

                # ファイルのカバレッジ情報を処理
                process_file_coverage "$FILE" "$FULL_PATH" "$FILE_COVERAGE" "$FILE_URL"
                
                # 処理済みとしてマーク
                PROCESSED_FILES["$FILE"]=1
              fi
            done
          done

          echo "</tbody>" >> $GITHUB_OUTPUT
          echo "</table>" >> $GITHUB_OUTPUT
          echo "" >> $GITHUB_OUTPUT
          ...

      - name: Comment PR
        if: steps.changed-files.outputs.has_changed_files == 'true'
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.XXXXX}}
          script: |
            const report = `${{ steps.report.outputs.report }}`;

            // PRにコメントする
            await github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: report
            });

3. Master Coverage Workflowの実装

毎週月曜日にmasterブランチのカバレッジを計測し、低カバレッジファイルのIssueを作成するためのGitHub Actionsワークフローを実装します。

  1. 全ファイルのテスト実行: masterブランチの全ファイルに対してテストを実行
  2. カバレッジ計測: プロジェクト全体のカバレッジを計測し、JSON形式で保存
  3. カバレッジファイル特定: カバレッジが80%未満のファイルを特定
    • ステートメント、ブランチ、関数、ラインのいずれかが80%未満
    • カバーされていない行の特定
  4. Issue作成: 低カバレッジファイルごとにGitHub Issueを作成
    • ファイル名とカバレッジ率を含むタイトル
    • 詳細なカバレッジ情報とカバーされていない行を含む本文
    • 「coverage」ラベルを付与
  5. Slack通知: カバレッジサマリーをSlackに通知
# .yml

name: "Master Coverage Report"
on:
  # スケジュール実行
  schedule:
    - cron: '0 11 * * 1'
  # 手動実行
  workflow_dispatch:

permissions:
  contents: read
  issues: write  # issueを作成するための権限

jobs:
  master-coverage:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: master

      - name: Install Node
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"

      - name: Install Dependencies
        run: cd app && npm install

      - name: Run Tests with Coverage
        run: cd app && npm run test:coverage
        env:
          TZ: "Asia/Tokyo"

      - name: Set File Pattern
        run: |
          # 対象とするファイル拡張子のパターンを環境変数として設定
          echo "FILE_PATTERN=\.vue$|\.ts$" >> $GITHUB_ENV

      - name: Generate Coverage Report
        id: report
        run: |
          # appディレクトリに移動
          cd app

          # リポジトリ情報
          REPO_URL="https://github.com/${{ github.repository }}"
          COMMIT_SHA="${{ github.sha }}"

          # カバレッジサマリーを読み込む(各指標で何%達成しているかを保持する)
          COVERAGE_SUMMARY=$(cat coverage/coverage-summary.json)

         ...

          # レポートの開始部分
          echo "# Master Branch Coverage Report" > $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "## Coverage Summary" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "<table>" >> $GITHUB_STEP_SUMMARY
          echo "<thead>" >> $GITHUB_STEP_SUMMARY
          echo "<tr>" >> $GITHUB_STEP_SUMMARY
          echo "<th align=\"left\">File</th>" >> $GITHUB_STEP_SUMMARY
          echo "<th align=\"right\">Stmts</th>" >> $GITHUB_STEP_SUMMARY
          echo "<th align=\"right\">Branches</th>" >> $GITHUB_STEP_SUMMARY
          echo "<th align=\"right\">Functions</th>" >> $GITHUB_STEP_SUMMARY
          echo "<th align=\"right\">Lines</th>" >> $GITHUB_STEP_SUMMARY
          echo "<th align=\"left\">Uncovered Lines</th>" >> $GITHUB_STEP_SUMMARY
          echo "</tr>" >> $GITHUB_STEP_SUMMARY
          echo "</thead>" >> $GITHUB_STEP_SUMMARY
          echo "<tbody>" >> $GITHUB_STEP_SUMMARY

          # Slackレポート用のヘッダー
          echo "File|Stmts|Branches|Funcs|Lines" > coverage_summary.txt

          # カバレッジが80%未満のファイルを記録するファイルを初期化
          > low_coverage_files.txt

          # 各フォルダごとに処理
          for FOLDER in $SORTED_FOLDERS; do
            ...


          echo "</tbody>" >> $GITHUB_STEP_SUMMARY
          echo "</table>" >> $GITHUB_STEP_SUMMARY

          # 全体のカバレッジサマリーを抽出
          TOTAL_COVERAGE=$(echo "$COVERAGE_SUMMARY" | jq -r ".total")
          TOTAL_STMTS=$(echo "$TOTAL_COVERAGE" | jq -r '.statements.pct')
          TOTAL_BRANCHES=$(echo "$TOTAL_COVERAGE" | jq -r '.branches.pct')
          TOTAL_FUNCS=$(echo "$TOTAL_COVERAGE" | jq -r '.functions.pct')
          TOTAL_LINES=$(echo "$TOTAL_COVERAGE" | jq -r '.lines.pct')

          # 全体のカバレッジサマリーをSlack用に保存
          echo "Total|$TOTAL_STMTS%|$TOTAL_BRANCHES%|$TOTAL_FUNCS%|$TOTAL_LINES%" >> coverage_summary.txt

          # 全体のカバレッジサマリーをステップのアウトプットに設定
          echo "total-stmts=$TOTAL_STMTS" >> $GITHUB_OUTPUT
          echo "total-branches=$TOTAL_BRANCHES" >> $GITHUB_OUTPUT
          echo "total-funcs=$TOTAL_FUNCS" >> $GITHUB_OUTPUT
          echo "total-lines=$TOTAL_LINES" >> $GITHUB_OUTPUT

          # 80%未満カバレッジファイルの数をステップのアウトプットに設定
          LOW_COVERAGE_COUNT=$(wc -l < low_coverage_files.txt)
          echo "low-coverage-count=$LOW_COVERAGE_COUNT" >> $GITHUB_OUTPUT

      - name: Create Issues for Low Coverage Files
        if: steps.report.outputs.low-coverage-count != '0'
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.XXXXX }}
          script: |
             ...

              // issueのタイトルを作成
              const title = `[Coverage] ${file} has low coverage (below 80%)`;

              // issueの本文を作成(基本的なカバレッジ情報のみ)
              let body = `## Coverage Report for \`${file}\`\n\n`;
              body += `このファイルは1つ以上のメトリクスで80%未満のカバレッジがあり、テストの改善が必要です。\n\n`;
              body += `| メトリクス | カバレッジ |\n`;
              body += `| ------ | -------- |\n`;
              body += `| Statements | ${stmts}% |\n`;
              body += `| Branches | ${branches}% |\n`;
              body += `| Functions | ${funcs}% |\n`;
              body += `| Lines | ${lines}% |\n\n`;

              if (uncoveredLines) {
                body += `### カバーされていない行\n\n`;
                body += `${uncoveredLines}\n\n`;
              }

              body += `Generated by [Master Coverage Report](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`;

              // 既存のissueを検索
              const existingIssues = await github.rest.issues.listForRepo({
                owner: context.repo.owner,
                repo: context.repo.repo,
                state: 'open',
                labels: 'coverage'
              });

              // 同じファイルに関する既存のissueを探す
              const existingIssue = existingIssues.data.find(issue =>
                issue.title === title || issue.title.includes(file)
              );

              // 既存のissueが見つかった場合はクローズする
              if (existingIssue) {
                console.log(`Closing existing issue #${existingIssue.number} for ${file}`);
                await github.rest.issues.update({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  issue_number: existingIssue.number,
                  state: 'closed',
                  state_reason: 'completed'
                });
              }

              // 新しいissueを作成
              const newIssue = await github.rest.issues.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title: title,
                body: body,
                labels: ['coverage']
              });


              // Issue番号を取得した後に、Devinへの依頼内容を追加
              updatedBody += `カバレッジを80%以上満たすようにユニットテストを作成して欲しいです。\n`;
              updatedBody += `特に上記の「カバーされていない行」に記載されている箇所をテストするようにしてください。\n\n`;
              updatedBody += `ファイルによってはテストファイルそのものが存在しない場合もあります。その際はファイル作成から行なってください。\n\n`;
              updatedBody += `テスト実装についてはknowledgeを確認してください。\n\n`;
              updatedBody += `実装が完了したら、\`npm run test:coverage\`を実行してカバレッジが80%以上になっていることを確認してください。\n\n`;
              updatedBody += `PRを作成する際は、PR説明文に「Closes #${newIssue.data.number}」と記載してください。これによりPRがマージされた時に自動的にこのissueが閉じられます。`;
              updatedBody += `\n\`\`\``;

              // Issueを更新
              await github.rest.issues.update({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: newIssue.data.number,
                body: updatedBody
              });
            }

      - name: Send Slack Notification
        run: |
          curl -X POST https://slack.com/api/chat.postMessage \
            -H "Authorization: Bearer ${{ secrets.XXXX}}" \
            -H "Content-type: application/json" \
            -d "{\"channel\":\"XXXXX\",\"unfurl_links\":false,\"blocks\":[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\":chart_with_upwards_trend: *Master Branch Coverage Report*\"}},{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"*全体カバレッジ:*\n• Statements: ${{ steps.report.outputs.total-stmts }}%\n• Branches: ${{ steps.report.outputs.total-branches }}%\n• Functions: ${{ steps.report.outputs.total-funcs }}%\n• Lines: ${{ steps.report.outputs.total-lines }}%\"}},{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"*80%未満カバレッジのファイル数:* ${{ steps.report.outputs.low-coverage-count }}\n${{ steps.report.outputs.low-coverage-count != '0' && 'これらのファイルに対してIssueが作成されました(一度の実行で最大10件)。' || '80%未満カバレッジのファイルはありません。' }}\"}},{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|詳細レポートを表示>\"}}]}"

Devinを活用したテスト作成の自動化

カバレッジ不足のファイルが特定されたら、Devinを活用してユニットテストを自動生成します。 このプロセスにより、テスト作成の負担を軽減し、効率的にカバレッジを向上させることができます。

Devinによるテスト作成プロセス:

  1. Master Coverage Workflowで作成されたIssueを確認
  2. Issueに記載されたプロンプトをもとにDevinにテスト作成を依頼
  3. Devinがコードを分析し、適切なユニットテストを作成
  4. テストを含むPRを作成
  5. PR Coverage Workflowによりカバレッジ向上を確認
  6. PRをレビュー・マージ

Devinにより作成されたPRの例 :

導入効果

カバレッジの継続的可視化と改善サイクルを導入したことで、以下のような効果が得られました:

1. コードレビューの効率化

  • PRごとにカバレッジレポートが自動生成されるため、新規ロジックのテスト漏れの特定が容易になりました
  • カバーされていない分岐やケースが明示的に表示されるため、レビュアーはそれらに焦点を当ててレビューできるようになりました
  • テストの網羅性を視覚的に確認できるため、レビュー時間が短縮されました

2. 既存コードの品質向上

  • テスト不足のロジックが週次で自動的に特定され、Issueとして可視化されるようになりました
  • AIを活用したテスト自動生成により、効率的にテストカバレッジを向上させることができるようになりました

今後の展望

カバレッジの継続的可視化と改善サイクルの導入により、コードの品質と開発効率が向上しましたが、さらなる改善のために以下の取り組みを検討しています:

1. カバレッジ目標の段階的引き上げ

現在は80%をカバレッジ目標としていますが、プロジェクトの成熟度に合わせて段階的に目標を引き上げていくことを検討しています。

2. 機能テストとの連携

ユニットテストカバレッジだけでなく、E2Eテストや統合テストとの連携を強化し、より包括的な品質保証体制を構築します。

3. Devinへのプロンプトをplaybookに移行する

プロンプト調整のために都度ワークフローを修正するのが面倒なので、Devinのplaybookを呼び出す形にすることを検討しています。 (Devinによるプルリクエスト作成時にチームをレビュワーとして指定することで、後はレビューするだけというところまで自動化できれば良さそう)

おわりに

ビザスクではエンジニアの仲間を募集しています! 少しでもビザスク開発組織にご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう! developer-recruit.visasq.works