👏

【Kotlin/Gradle】JUnit 5のテスト出力をtestLoggingでカスタマイズし、結果を見やすくする in VSCode

に公開

Gradleでユニットテスト実行時の結果の確認方法と問題点

  1. 標準出力
      - 圧倒的に情報がたりない
  2. /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