👏
【Kotlin/Gradle】JUnit 5のテスト出力をtestLoggingでカスタマイズし、結果を見やすくする in VSCode
Gradleでユニットテスト実行時の結果の確認方法と問題点
- 標準出力
- 圧倒的に情報がたりない - /build/reports/tests/test/index.html
- ブラウザで確認できるが、リロードが必要
- LiveServerでリロードせず自動更新できる?
- エラー内容を確認するのにクリックが必要
- <<<めんどくさい>>>
- ブラウザで確認できるが、リロードが必要
出力情報を増やす
build.gradle.kts
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
// exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.SHORT デフォルト
showExceptions = true // 例外を表示
showCauses = true // 原因チェーンを表示
showStandardStreams = true // 標準出力を表示
}
}
出力。ログが多すぎてよくわかんない。
...
...
...
失敗時のみログを出すようにする
build.gradle.kts
// 失敗テストの出力だけ後でまとめて表示
val buf = ConcurrentHashMap<TestDescriptor, StringBuilder>()
addTestOutputListener(object : TestOutputListener {
override fun onOutput(d: TestDescriptor, e: TestOutputEvent) {
buf.computeIfAbsent(d) { StringBuilder() }.append(e.message)
}
})
addTestListener(object : TestListener {
override fun afterTest(d: TestDescriptor, r: TestResult) {
if (r.resultType == TestResult.ResultType.FAILURE) {
val out = buf.remove(d)?.toString()?.trim()
if (!out.isNullOrEmpty())
logger.lifecycle("\n=== Output for ${d.className}.${d.name} ===\n$out\n")
} else {
buf.remove(d) // 合格なら捨てる
}
}
override fun beforeTest(d: TestDescriptor) {}
override fun beforeSuite(s: TestDescriptor) {}
override fun afterSuite(s: TestDescriptor, r: TestResult) {}
})
出力。INFO, DEBUGが鬱陶しい。
2025-08-14 13:58:28.417 INFO 1907 --- [ Test worker] ...
2025-08-14 13:58:28.417 INFO 1907 --- [ Test worker] ...
2025-08-14 13:58:28.419 INFO 1907 --- [ Test worker] ...
2025-08-14 13:58:28.424 DEBUG 1907 --- [ Test worker] ...
2025-08-14 13:58:28.425 DEBUG 1907 --- [ Test worker] ...
INFO, DEBUGを出力しないように変更
build.gradle.kts
addTestOutputListener(object : TestOutputListener {
override fun onOutput(d: TestDescriptor, e: TestOutputEvent) {
val sb = buf.computeIfAbsent(d) { StringBuilder() }
if (e.destination == TestOutputEvent.Destination.StdErr) {
// stderr はそのまま(スタックトレース等)
sb.append(e.message)
} else {
// stdout はログレベルでフィルタ。Spring Boot の既定フォーマット想定(" WARN " / " ERROR " が入る)
e.message.lineSequence()
.filter { it.contains(" WARN ") || it.contains(" ERROR ") || it.contains(" FATAL ") }
.forEach { sb.append(it).append('\n') }
}
}
})
出力。いいかんじ。
※自分でログを出したい場合はwarn以上にする必要あり
> Task :test
Controllerのテスト > 更新のテスト FAILED
java.lang.AssertionError: Status expected:<200> but was:<400>
at org.springframework.test.util.AssertionErrors.fail(AssertionErrors.java:59)
at org.springframework.test.util.AssertionErrors.assertEquals(AssertionErrors.java:122)
at org.springframework.test.web.servlet.result.StatusResultMatchers.lambda$matcher$9(StatusResultMatchers.java:627)
at org.springframework.test.web.servlet.MockMvc$1.andExpect(MockMvc.java:212)
at org.springframework.test.web.servlet.result.StatusResultMatchersDsl.isOk(StatusResultMatchersDsl.kt:128)
at ...
at ...
2025-08-14 15:28:57.863 INFO 4217 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2025-08-14 15:28:57.875 INFO 4217 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
リザルトを表示、対象のテストがない場合にメッセージを表示
build.gradle.kts
addTestListener(object: TestListener {
override fun afterSuite(desc: TestDescriptor, result: TestResult) {
if (desc.parent == null) {
val total = result.testCount
val failed = result.failedTestCount
val skipped = result.skippedTestCount
val passed = total - failed - skipped
logger.lifecycle(
"\n[tests] ✅ passed: {} ❌ failed: {} ⚠️ skipped: {} 🧪 total: {}\n",
passed, failed, skipped, total
)
if (total == 0L) {
logger.lifecycle(
"\n[gradle-test] No tests were executed.\n" +
"Check your --tests pattern (e.g. '*ControllerTest' or a fully-qualified name).\n"
)
}
}
}
})
filter { isFailOnNoMatchingTests = false }
出力。見やすくなってよかった
> Task :test
2025-08-14 16:05:36.629 INFO 4556 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2025-08-14 16:05:36.649 INFO 4556 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
[tests] ✅ passed: 8 ❌ failed: 0 ⚠️ skipped: 0 🧪 total: 8
成功、スキップを表示
テストに時間がかかっていてなにやってるかわからなかったので追加
override fun afterTest(d: TestDescriptor, r: TestResult) {
when (r.resultType) {
TestResult.ResultType.FAILURE -> {
val out = buf.remove(d)?.toString()?.trim()
if (!out.isNullOrEmpty())
logger.lifecycle("\n=== Output for ${d.className}.${d.name} ===\n$out\n")
}
TestResult.ResultType.SKIPPED -> {
logger.lifecycle("⚠️ SKIPPED: ${d.className}.${d.name}")
buf.remove(d)
}
else -> {
buf.remove(d) // 合格なら捨てる
}
}
}
override fun beforeTest(d: TestDescriptor) {
logger.lifecycle("🧪 Starting: ${d.className}.${d.name}")
}
出力。これで何やってるかわかる。可能なら何秒かかるかを表示したかったが、1行では厳しそう。
Task :test
🧪 Starting: { package name } { method name }
⚠️ SKIPPED: { package name } { method name }
2025-09-01 08:38:50.831 INFO 48452 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
[tests] ✅ passed: 129 ❌ failed: 0 ⚠️ skipped: 1 🧪 total: 130
完成版
build.gradle.kts
tasks.withType<Test> {
useJUnitPlatform()
testLogging {
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showExceptions = true // 例外を表示
showCauses = true // 原因チェーンが欲しい場合
showStandardStreams = false // ← 全テストでの出力は止める
events = setOf(TestLogEvent.FAILED) // ← 失敗イベントだけログ行に出す
}
// 失敗テストの出力だけ後でまとめて表示
val buf = ConcurrentHashMap<TestDescriptor, StringBuilder>()
// addTestOutputListener(object : TestOutputListener {
// override fun onOutput(d: TestDescriptor, e: TestOutputEvent) {
// buf.computeIfAbsent(d) { StringBuilder() }.append(e.message)
// }
// })
addTestOutputListener(object : TestOutputListener {
override fun onOutput(d: TestDescriptor, e: TestOutputEvent) {
val sb = buf.computeIfAbsent(d) { StringBuilder() }
if (e.destination == TestOutputEvent.Destination.StdErr) {
// stderr はそのまま(スタックトレース等)
sb.append(e.message)
} else {
// stdout はログレベルでフィルタ。Spring Boot の既定フォーマット想定(" WARN " / " ERROR " が入る)
e.message.lineSequence()
// .filter { it.contains(" WARN ") || it.contains(" ERROR ") || it.contains(" FATAL ") }
.filter { it.contains("DEBUG") || it.contains(" WARN ") || it.contains(" ERROR ") || it.contains(" FATAL ") }
.forEach { sb.append(it).append('\n') }
}
}
})
addTestListener(object : TestListener {
override fun afterTest(d: TestDescriptor, r: TestResult) {
when (r.resultType) {
TestResult.ResultType.FAILURE -> {
val out = buf.remove(d)?.toString()?.trim()
if (!out.isNullOrEmpty())
logger.lifecycle("\n=== Output for ${d.className}.${d.name} ===\n$out\n")
}
TestResult.ResultType.SKIPPED -> {
logger.lifecycle("⚠️ SKIPPED: ${d.className}.${d.name}")
buf.remove(d)
}
else -> {
buf.remove(d) // 合格なら捨てる
}
}
}
override fun afterSuite(desc: TestDescriptor, result: TestResult) {
if (desc.parent == null) {
val total = result.testCount
val failed = result.failedTestCount
val skipped = result.skippedTestCount
val passed = total - failed - skipped
logger.lifecycle(
"\n[tests] ✅ passed: {} ❌ failed: {} ⚠️ skipped: {} 🧪 total: {}\n",
passed, failed, skipped, total
)
if (total == 0L) {
logger.lifecycle(
"\n[gradle-test] `!!!!!!!!!!!!!!!!!!!!!!No tests were executed.!!!!!!!!!!!!!!!!!!!!!\n" +
"Check your --tests pattern (e.g. '*ControllerTest' or a fully-qualified name).\n" +
"\n[gradle-test] `!!!!!!!!!!!!!!!!!!!!!!No tests were executed.!!!!!!!!!!!!!!!!!!!!!\n"
)
}
}
}
override fun beforeSuite(s: TestDescriptor) {}
override fun beforeTest(d: TestDescriptor) {
logger.lifecycle("🧪 Starting: ${d.className}.${d.name}")
}
})
filter { isFailOnNoMatchingTests = false }
}
Discussion