Projects · Software · Publications

Gradle Test Reporting

If you want a more complex error reporting, e.g., to find failing test cases more easy, you can write a Task which transforms the JUnit XML reports into a Markdown file. If you use $GITHUB_STEP_SUMMARY, it will be printed on workflow overview.

import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileTree
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.*
import org.w3c.dom.NodeList
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathConstants
import javax.xml.xpath.XPathFactory

/**
 * A Task reading JUnit XML reports and generating a simple Markdown report for GitHub workflows.
 *
 * @author Alexander Weigl
 * @version 1 (2025-12-26)
 */
abstract class JUnitMarkdownReporter : DefaultTask() {
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val testReports: Property<ConfigurableFileTree>

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    private val builderFactory = DocumentBuilderFactory.newInstance()
    private val builder = builderFactory.newDocumentBuilder()
    private val xPath = XPathFactory.newInstance().newXPath()

    private val xpathFailingTestcases = "/testsuite/testcase[./error|./failure]"
    private val xpathCFailingTestcases = xPath.compile(xpathFailingTestcases)

    private val xpathNumberTests = "/testsuite/@tests"
    private val xpathCNumberTests = xPath.compile(xpathNumberTests)

    init {
        val pathname = System.getenv("GITHUB_STEP_SUMMARY") ?: "build/reports/junit.md"
        outputFile.set(File(pathname))
    }

    @TaskAction
    fun run() {
        val errors = arrayListOf<String>()
        outputFile.get().asFile.bufferedWriter().use { writer ->
            var cntFiles = 0
            var testCases = 0
            testReports.get().forEach { file ->
                testCases += process(file, errors)
                cntFiles++
            }

            if (errors.isNotEmpty()) {
                writer.write(errors.joinToString("\n") { "* $it" })
            } else {
                writer.write("No errors in $cntFiles JUnit files. Found $testCases tests.")
            }
        }
    }

    private fun process(file: File, errors: MutableList<String>): Int {
        val xmlDocument = builder.parse(file)
        val testCount = xpathCNumberTests.evaluate(xmlDocument, XPathConstants.NUMBER) as Number

        val nodeList = xpathCFailingTestcases.evaluate(xmlDocument, XPathConstants.NODESET) as NodeList?
        if (nodeList != null && errors.isNotEmpty()) {
            for (i in 0 until nodeList.length) {
                val node = nodeList.item(i)
                val name = node.attributes.getNamedItem("name").nodeValue
                val classname = node.attributes.getNamedItem("classname").nodeValue
                val time = node.attributes.getNamedItem("time").nodeValue
                val error = node.firstChild
                errors.add("Error: $classname#$name in $time")
            }
        }
        return testCount.toInt()
    }
}

Usage:

tasks.register<JUnitMarkdownReporter>("githubReporter") {
    group = "verification"
    testReports.set(
      fileTree(rootDir) {
        include("*/build/test-results/test/TEST-*.xml")
      }
    )
}

#Gradle   #Github