はじめに
私の所属するグループでは、半年に1回、グループ全体でのイベントを開催しています。
今回は「チーム対抗で1日で特定パッケージのJUnitテストカバレッジをどれだけ上げられるか」を競うイベントを企画しました。この記事では、そのイベントを盛り上げるために作った、準リアルタイムのカバレッジ計測ツールについて紹介します。
普段インフラ寄りの作業はほとんどしない私ですが、Gradleのカスタムタスクを触ることになったので、その過程で得た知見をまとめます。
なお、本ツールは社内ルールに従い、外部サービス(Googleドライブ・GAS)へ渡す情報を「カバレッジ集計に必要な非機微情報(パッケージ名・カバレッジ率などの数値)」のみに限定しています。個人情報・HRデータ・機密コードといった情報は一切含めていません。外部サービスを利用する際は、必ず自組織のルールに従い、送信するデータの範囲を確認してください。
課題:リアルタイム性が欲しかった
1日でタイムアタック的に進行するイベントだったので、「今どのチームが勝っているか」がすぐ分かるような、リアルタイム性のある盛り上がりを演出したいと考えました。
カバレッジ計測というと、SonarQube(静的解析やコード品質を可視化するツール)で見るのが一般的です。しかし、SonarQubeの解析はCIのタイミングでしか実行されないため、リアルタイム性に欠け、今回のイベントの進行速度には合いませんでした。
解決の方向性
「手元で手軽に実行して、その場でカバレッジが分かればいいな」というのが最初のイメージでした。
最初はSonarQubeのWeb APIをPythonなどのスクリプトから叩く方法も検討しましたが、AIエージェント(Devin)に相談しながら壁打ちした結果、以下のような仕組みに落ち着きました。
- 各PCの手元でJUnitテストを実行し、カバレッジをローカルにCSVとして出力する
- 各PCにGoogleドライブを連携しておき、CSV(カバレッジ率などの数値情報のみ)を自動でアップロードすることで、各チームのカバレッジ状況を準リアルタイムに把握できるようにする
- アップロードされたCSVを、ドライブ上でGAS(Google Apps Script。Googleドライブ上のスプレッドシート等を操作できるスクリプト環境)により5分間隔で集計する
この流れを頭の中でイメージした時点で、「これは白熱しそうだ」と確信しました。
実装:Gradleカスタムタスクとして組み込む
アイデアが固まったので、Devin CLI(AIコーディングエージェント)を使いながら実装を進めました。既存のGradleプロジェクト(Gradleバージョンは8.10)に対して、JUnitテストの実行とカバレッジ取得を自動化するタスクを追加する形です。
カバレッジの計算にはJaCoCo Gradle Plugin(JaCoCoをGradleから利用するための公式プラグイン)を使い、指定したパッケージのカバレッジをCSVに出力するようにしました。
なお、今回のGradleへの変更はイベント専用の設定であり、プロダクトコードには含めたくありませんでした。そこで、イベント用のブランチを1本切り、参加者にはそのブランチをチェックアウトしてもらう形で運用しました。イベント中に各チームが実装したJUnitテストは、後日Cherry-pick(特定コミットだけを別ブランチに反映させる操作)などをして、別ブランチから通常のPRとして取り込む予定です。
これらを packageCoverage というカスタムタスクとしてまとめ、以下のような処理フローにしました。
packageCoverage
└─ packageCoverageReport
└─ packageCoverageTest
└─ validateCoveragePackage
実際の処理の流れは次の通りです。
- パラメーターと対象パッケージを検証する
- 指定テストパッケージ配下のJUnitだけを実行する
- JaCoCoの実行データを生成する
- 指定した本体パッケージ配下のclassだけをXMLレポート化する
- 行・分岐カバレッジを計算する
- ローカルCSVへ履歴を追記する
- 必要に応じて共有ドライブのチーム別CSVを更新する
validateCoveragePackage:まず入力を検証する
最初に、以下のような入力値を検証します。
-
coverage.packageが指定されているか - Javaパッケージ名として妥当か
- 対象モジュールの
src/main/javaに対象パッケージが存在するか -
src/test/javaに対象テストパッケージが存在するか - 共有設定を使う場合、チーム名と共有フォルダが両方指定されているか
- 共有フォルダが書込み可能か
def validateCoveragePackage = tasks.register('validateCoveragePackage') {
group = 'verification'
description = 'Validates the packages specified for package coverage measurement.'
doLast {
if (!coveragePackage.isPresent()) {
throw new GradleException(
'Specify a package with -Pcoverage.package=<package-name>.'
)
}
}
}
テスト実行後ではなく、テスト実行「前」に入力不備を検出することで、時間のかかるテスト実行を無駄にせずに済みます。イベントは時間との勝負なので、この地味な検証が地味に重要でした。
packageCoverageTest:対象パッケージのテストだけを実行する
Test 型を継承したカスタムタスクです。
def packageCoverageTest = tasks.register('packageCoverageTest', Test) {
group = 'verification'
description = 'Runs unit tests under -Pcoverage.testPackage.'
dependsOn validateCoveragePackage
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
excludeTags 'Isolated'
}
doFirst {
filter.includeTestsMatching("${coverageTestPackage.get()}.*")
}
}
ここで重要なのは filter.includeTestsMatching です。FQCN(完全修飾クラス名)パターンで対象テストを絞り込むことで、モジュール全体ではなく、指定パッケージとそのサブパッケージのテストだけを実行できます。
また、Isolated タグの付いたテストは除外しています。環境依存だったり単独実行が前提のテストを、イベントの通常測定に混ぜないようにするためです。
packageCoverageReport:対象パッケージだけを集計する
テスト結果からJaCoCoのXMLレポートを生成します。
def packageCoverageReport = tasks.register('packageCoverageReport', JacocoReport) {
dependsOn packageCoverageTest
executionData(packageCoverageTest.get())
sourceDirectories.from(sourceSets.main.allJava.srcDirs)
classDirectories.from(coveragePackage.map { packageName ->
String packagePath = packageName.replace('.', '/')
project.fileTree('build/classes/java/main') {
include "${packagePath}/**/*.class"
}
})
reports {
xml.required = true
html.required = false
}
}
classDirectories を指定パッケージのclassファイルだけに絞ることで、テストの実行範囲だけでなく、レポートの集計範囲も対象パッケージに限定しています。
HTMLレポートはイベント用途では不要なので生成せず、後続処理に必要なXMLだけを出力するようにしました。
packageCoverage:カバレッジを計算してCSVへ出力する
最終タスクでは、JaCoCoのXMLからカウンターを読み込み、以下の式でカバレッジ率を計算してCSVへ記録します。
(実行された行数 + 実行された分岐数)
-------------------------------- × 100
(計測対象の総行数 + 計測対象の総分岐数)
出力するCSVは次のような形式です。
timestamp,module,package,coverage_percent,line_covered,line_total,branch_covered,branch_total
例:
2026-08-26T21:00:00+09:00,:sample-app,com.example.logging,53.74,295,546,100,189
設定はプロパティ・環境変数・ファイルの優先順位で解決
共有ドライブ同期に使うチーム名と出力先は、次の優先順位で解決するようにしました。
- Gradleプロパティ(
-Pcoverage.team/-Pcoverage.sharedDirectory) - 環境変数(
COVERAGE_TEAM/COVERAGE_SHARED_DIRECTORY) .coverage/config.properties
ローカル設定ファイルの例は以下の通りです。
coverage.team=team-a
coverage.sharedDirectory=/mnt/g/共有ドライブ/coverage-event
.coverage/ は .gitignore に追加し、端末固有のパスやチーム設定、測定履歴がリポジトリに紛れ込まないようにしています。
共有CSVの更新は一時ファイル経由で
共有設定がある場合、ローカルの履歴CSV(.coverage/history.csv)を、共有フォルダ上のチーム別CSV(<共有フォルダ>/<team>.csv)へ同期します。
更新時は、いきなり本体ファイルへ上書きするのではなく、一時ファイルへ書き込んでからリネームで置き換える方式にしました。
.<team>.csv.tmp
↓ atomic move(同名ファイルへの置き換え)
<team>.csv
これは、Googleドライブなどの同期中に、GASや他の参加者が「書き込み途中の壊れたCSV」を読んでしまう可能性を減らすためです。
ただし、この atomic move はあくまでローカルファイル更新時の安全策であり、Googleドライブなどのクラウド同期そのものの整合性・タイミングまで保証するものではありません。同期には数秒〜数十秒のラグが生じる場合があり、また同一チームで複数のPCが同時に測定した場合の履歴競合までは完全には防げません。イベント運用上は、チームごとに測定担当者・測定用PCを1台に決めておくのが安全です。
SonarQubeの数値とは一致しない
このツールの数値は、SonarQubeの計測結果と完全に一致することを目的としていません。理由は以下の通りです。
- 対象外パッケージにあるテストは実行しない
- 結合テストは実行しない
-
Isolatedタグの付いたテストを除外する - SonarQube側の除外設定・解析条件と異なる可能性がある
大事なのは「絶対値の正確さ」ではなく、イベント参加者全員が同じ条件で測定し、開始時からどれだけ改善したかを比較できることです。
可視化:GASでスプレッドシートに集約
最後に、共有ドライブ上のチーム別CSVをGASでGoogleスプレッドシートへ集約し、ダッシュボードとして可視化しました。
各チームの packageCoverage 実行結果
↓
共有ドライブ上の team-a.csv / team-b.csv
↓
Google Apps Script(5分に1回集計)
↓
Google Sheets のランキング・ダッシュボード
ダッシュボードでは、開始時の値・最新値・上昇幅・最終更新時刻・チーム別の推移が一目で分かるようにしました。
本記事はGradleに関する実装を中心に解説するため、GASの実装は省略します。
当日の様子
実際にイベント当日は、「あと3%であのチームまくれるぞ!」といった声が上がるなど、狙い通り白熱した展開になりました。5分間隔とはいえ、準リアルタイムでカバレッジの推移が見えることで、ゴール直前の追い上げがドラマチックに見えるのが良かったです。
実装にかかった工数
ツールの実装には、Devinを使ったGradle構築に2時間、GASの実装に1時間、合計で3時間ほどかかりました。イベント本番までの限られた時間の中で、AIエージェントに壁打ちしながら進めたことで、コスパ良く形にできた実感があります。
なお、このツールは「1日だけ動けばよい」という前提だったため、今のところ追加の改善予定は特にありません。
まとめ
今回のポイントは以下の3つです。
-
Test型のカスタムタスクで、対象パッケージのテストだけを実行する -
JacocoReportのclassDirectoriesで、対象パッケージだけを集計する - Gradleの実行結果をCSV化し、Googleスプレッドシートでイベントの進捗比較に利用する
Gradleは、既存の java プラグインや jacoco プラグインを土台にしつつ、タスク依存関係・入力検証・テストフィルター・レポート生成・ファイル出力を組み合わせることで、用途に合わせた開発支援ツールを作れます。インフラエンジニアではない私にとっても、良い勉強になりました。