lastminute.com logo

Technology

Using Gradle Kotlin DSL with junit 5

staff
staff

Gradle 5 has been out for a while now and with that we finally got the ability to write our Gradle scripts in Kotlin. How can I migrate my JUnit-enabled Gradle scripts to Kotlin Gradle DSL?


JUnit 5 and Gradle

Gradle 5+ has been out for a while now and with that we finally got the ability to write our Gradle scripts in Kotlin. Almost every example out there about JUnit and Gradle is still using the old dependencies or Groovy Gradle syntax, so let’s try to migrate a Groovy build using JUnit Platform to Kotlin DSL, specifically, based on examples about how to extend Gradle’s test output to add summaries at the end of the build.

What we will achieve

We are starting from a plain Groovy Gradle script that configures a test task to log our JUnit test status (like examples here or here )

In addition, we will enable spotless and checkstyle in our Kotlin project as well as configuring IntelliJ IDEA Gradle plugin to download javadoc and sources of our dependencies.

Starting point

So let’s see how an old Groovy build.gradle is like when we want to use JUnit 5:

test {
    testLogging {
        exceptionFormat = 'full'
        events 'FAILED', 'SKIPPED', 'PASSED'
        showStandardStreams = true
        afterSuite { desc, result ->
            if (!desc.parent) {
                println "\nTest result: ${result.resultType}"
                println "Test summary: ${result.testCount} tests, " +
                        "${result.successfulTestCount} succeeded, " +
                        "${result.failedTestCount} failed, " +
                        "${result.skippedTestCount} skipped"
            }
        }
    }

    useJUnitPlatform {}
}

cleanTest {}

configure(cleanTest) { group = 'verification' }

This is the very basic example we are going to find in stackoverflow or other forums.

We have a closure to display some test statistics, like the number of passed, failed and skipped tests. We also instruct Gradle to use Junit Platform (Junit5) with the useJUnitPlatform {} instruction and we also add the cleanTest task to the verification group (any clean<Task> in Gradle will delete the results by <Task>).

This last bit adds the task to our IDE Gradle tasklist in the desired group.

Migrating to Gradle Kotlin DSL

As with every software engineering project, our task to migrate can be broken in smaller chunks and we are going to start with the MVP, which is being able to build and test our project. Since we are testing, we will add also our test framework dependencies: mockk and assertj:

repositories {
    mavenCentral()
}

plugins {
    kotlin("jvm") version "1.3.40"
}


dependencies {
    implementation(kotlin("stdlib-jdk8"))

    testImplementation("io.mockk:mockk:1.9.3")
    testImplementation("org.assertj:assertj-core:3.11.1")
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.4.2")
    testImplementation("org.junit.jupiter:junit-jupiter-params:5.4.2")

    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.4.2")
}

test {
    useJunitPlatform()
}

But when we reimport the project… k-boom!

  Line 21: test {

^ Expression 'test' cannot be invoked as a function. The function 'invoke()' is not found
...

Why is this not working? Well, accessors for tasks are not published outside the tasks container (or accessor) on Gradle’s Kotlin DSL.

In order to solve this, we can use tasks and then filter by using one of the available methods:

Selecting a hardcoded taskname inside tasks block

tasks {
    "test"(Test::class) {
        useJUnitPlatform()
    }
}

Notice how the class of the tasks is specified by Test::class, thus allowing us to invoke useJUnitPlatform() method of the Gradle Test class. However we do not like hardcoded strings… like "test". Fortunately, we can simply modify the behavior of a list of tasks easily:

Filtering through the existing tasks

tasks.withType<Test> {

    useJUnitPlatform()
}

Much more readable! And also will apply to any Test task defined on our build.

By task accessor inside the task block (requires)

tasks {
    test {
        useJUnitPlatform()
    }
}

Even better! we do not need to know the type of the class since this is a type-safe accessor and comes with full IDE integration like code completion and inspections.

There are many other ways to access the builtin (or plugin added) tasks. Just check Gradle documentation

Logging JUnit output

Since the accessors are now type-safe and we are working with Test tasks, we believe can invoke some of our previous functionality almost by copy and pasting from our original implementation.

But unfortunately, the following snippet breaks our build:

tasks {

    test {
        useJUnitPlatform()

        testLogging {
            exceptionFormat = 'full'
            events 'FAILED', 'SKIPPED', 'PASSED'
            showStandardStreams = true
            afterSuite { desc, result ->
                if (!desc.parent) {
                    println "\nTest result: ${result.resultType}"
                    println "Test summary: ${result.testCount} tests, " +
                    "${result.successfulTestCount} succeeded, " +
                            "${result.failedTestCount} failed, " +
                            "${result.skippedTestCount} skipped"
                }
            }
        }
    }
}

Unexpected tokens (use ';' to separate expressions on the same line). This refers to the events list we are filtering.

Since we are not using Groovy anymore, we need to convert them from hardcoded strings to the proper objects, and also use the right assignment with =

The same applies to the exceptionFormat so let’s also fix that. Don’t forget to import the new classes!

import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent

repositories {
    mavenCentral()
}

plugins {
    kotlin("jvm") version "1.3.40"
}

/*...*/

tasks {

    test {

        useJUnitPlatform()

        testLogging {
            exceptionFormat = TestExceptionFormat.FULL
            events = mutableSetOf(TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED)
            showStandardStreams = true
            afterSuite { desc, result ->
                if (!desc.parent) {
                    println "\nTest result: ${result.resultType}"
                    println "Test summary: ${result.testCount} tests, " +
                    "${result.successfulTestCount} succeeded, " +
                            "${result.failedTestCount} failed, " +
                            "${result.skippedTestCount} skipped"
                }
            }
        }
    }
}

Ok, we fixed the imports but we are still facing the most difficult part so far.

Kotlin does not have Closure and our after suite block returns a pair of Nothing:

Type mismatch: inferred type is (Nothing, Nothing) -> TypeVariable(_L) but Closure<(raw) Any!>! was expected

At this point we might consider importing Groovy’s Closure but our objective was using Kotlin DSL and removing the Groovy dependency so there must be an alternative.

Digging on the internet cough, cough, over here we see that we can just add a TestListener and override the afterSuite method (which in fact is what the closure was doing):

/*...*/

tasks {

    test {

        useJUnitPlatform()

        addTestListener(object : TestListener {
            override fun beforeSuite(suite: TestDescriptor) {}
            override fun beforeTest(testDescriptor: TestDescriptor) {}
            override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {}

            override fun afterSuite(suite: TestDescriptor, result: TestResult) {
                if (suite.parent == null) { // root suite
                    logger.info("----")
                    logger.info("Test result: ${result.resultType}")
                    logger.info("Test summary: ${result.testCount} tests, " +
                        "${result.successfulTestCount} succeeded, " +
                        "${result.failedTestCount} failed, " +
                        "${result.skippedTestCount} skipped")

                }
            }
        })
    }
}

It is a pity that we don’t have an adapter with default empty implementations and we need to implement empty methods but this also opens the doors to some evolution in our build setup.

First we introduced the logger object, that the Kotlin DSL for Gradle exposes so we can log events. We have debug, info and all the familiar LOG_LEVELS but there is also an interesting log level called LIFECYCLE.

Since we (arguably) think that test results do not belong to info level but lifecycle instead, we will redefine the level we are logging test events.

We need to remember to properly configure the new lifecycle level we are using:

/*...*/

tasks {

    test {

        useJUnitPlatform()

        testLogging {
            lifecycle {
                events = mutableSetOf(TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED)
                exceptionFormat = TestExceptionFormat.FULL
                showExceptions = true
                showCauses = true
                showStackTraces = true
                showStandardStreams = true
            }
            info.events = lifecycle.events
            info.exceptionFormat = lifecycle.exceptionFormat
        }

        // See https://github.com/gradle/kotlin-dsl/issues/836
        addTestListener(object : TestListener {
            override fun beforeSuite(suite: TestDescriptor) {}
            override fun beforeTest(testDescriptor: TestDescriptor) {}
            override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {}

            override fun afterSuite(suite: TestDescriptor, result: TestResult) {
                if (suite.parent == null) { // root suite
                    logger.lifecycle("----")
                    logger.lifecycle("Test result: ${result.resultType}")
                    logger.lifecycle("Test summary: ${result.testCount} tests, " +
                        "${result.successfulTestCount} succeeded, " +
                        "${result.failedTestCount} failed, " +
                        "${result.skippedTestCount} skipped")
                }
            }
        })
    }
}

Second, we can add some new logging details by overriding other functions. Let’s do a more detailed summary of failed and skipped tests:

/*...*/

tasks {

    test {

        /*...*/

        val failedTests = mutableListOf<TestDescriptor>()
        val skippedTests = mutableListOf<TestDescriptor>()

        // See https://github.com/gradle/kotlin-dsl/issues/836
        addTestListener(object : TestListener {
            override fun beforeSuite(suite: TestDescriptor) {}
            override fun beforeTest(testDescriptor: TestDescriptor) {}
            override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {
                when(result.resultType) {
                    TestResult.ResultType.FAILURE -> failedTests.add(testDescriptor)
                    TestResult.ResultType.SKIPPED -> skippedTests.add(testDescriptor)
                }
            }

            override fun afterSuite(suite: TestDescriptor, result: TestResult) {
                if (suite.parent == null) { // root suite
                    logger.lifecycle("----")
                    logger.lifecycle("Test result: ${result.resultType}")
                    logger.lifecycle("Test summary: ${result.testCount} tests, " +
                        "${result.successfulTestCount} succeeded, " +
                        "${result.failedTestCount} failed, " +
                        "${result.skippedTestCount} skipped")
                    if (failedTests.isNotEmpty()) {
                        logger.lifecycle("\tFailed Tests:")
                        failedTests.forEach {
                            parent?.let { parent ->
                                logger.lifecycle("\t\t${parent.name} - ${it.name}")
                            } ?: logger.lifecycle("\t\t${it.name}")
                        }
                    }

                    if (skippedTests.isNotEmpty()) {
                        logger.lifecycle("\tSkipped Tests:")
                        skippedTests.forEach {
                            parent?.let { parent ->
                                logger.lifecycle("\t\t${parent.name} - ${it.name}")
                            } ?: logger.lifecycle("\t\t${it.name}")
                        }
                    }
                }
            }
        })
    }
}

First we declare to mutableLists, one for failed and the other one for skipped tests. Then we check what kind of TestResult we are getting and add the TestDescriptor to the right list. One good thing from Kotlin scripting is that we can omit the else -> in the enum branching.

However, being able to use Kotlin, we can improve somehow the script with all the fancy Kotlin features.

For example, we can use extension functions to make the code more readable.

Also, since we are going to add some linter capabilities, we might want to adhere to best practices and use proper when branching and include the ELSE fallback.

How does our example build.gradle.kts look after all the optimizations?

  • We added some checkstyle, spotless and linter configurations as well as IntelliJ IDEA plugin.
  • We setup some configuration options for the plugins above.
  • We refactored how we log to use a functional approach and Kotlin features.
  • Since we love testing with ParameterizedTests, we added that bit for JUnit too.
  • Finally, we used the same approach than on the Test task customization to customize the Kotlin compile options.
  • As a bonus, we are passing spotless / checkstyle to all the Gradle.kts files in the KotlinGradle task in the spotless configuration.

The end result is:

import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent

repositories {
    mavenCentral()
}

plugins {
    kotlin("jvm") version "1.3.40"
    id("com.diffplug.gradle.spotless") version "3.23.1"
    id("org.jmailen.kotlinter") version "1.26.0"
    checkstyle
    idea
}

idea {
    module {
        isDownloadJavadoc = true
        isDownloadSources = true
    }
}

tasks.checkstyleMain { group = "verification" }
tasks.checkstyleTest { group = "verification" }

spotless {
    kotlin {
        ktlint()
    }
    kotlinGradle {
        target(fileTree(projectDir).apply {
            include("*.gradle.kts")
        } + fileTree("src").apply {
            include("**/*.gradle.kts")
        })
        ktlint()
    }
}

dependencies {
    implementation(kotlin("stdlib-jdk8"))

    testImplementation("io.mockk:mockk:1.9.3")
    testImplementation("org.assertj:assertj-core:3.11.1")
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.4.2")
    testImplementation("org.junit.jupiter:junit-jupiter-params:5.4.2")

    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.4.2")
}

tasks.compileKotlin {
    kotlinOptions {
        jvmTarget = "1.8"
        javaParameters = true
    }
}

tasks.test {
    useJUnitPlatform()

    testLogging {
        lifecycle {
            events = mutableSetOf(TestLogEvent.FAILED, TestLogEvent.PASSED, TestLogEvent.SKIPPED)
            exceptionFormat = TestExceptionFormat.FULL
            showExceptions = true
            showCauses = true
            showStackTraces = true
            showStandardStreams = true
        }
        info.events = lifecycle.events
        info.exceptionFormat = lifecycle.exceptionFormat
    }

    val failedTests = mutableListOf<TestDescriptor>()
    val skippedTests = mutableListOf<TestDescriptor>()

    // See https://github.com/gradle/kotlin-dsl/issues/836
    addTestListener(object : TestListener {
        override fun beforeSuite(suite: TestDescriptor) {}
        override fun beforeTest(testDescriptor: TestDescriptor) {}
        override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {
            when (result.resultType) {
                TestResult.ResultType.FAILURE -> failedTests.add(testDescriptor)
                TestResult.ResultType.SKIPPED -> skippedTests.add(testDescriptor)
                else -> Unit
            }
        }

        override fun afterSuite(suite: TestDescriptor, result: TestResult) {
            if (suite.parent == null) { // root suite
                logger.lifecycle("----")
                logger.lifecycle("Test result: ${result.resultType}")
                logger.lifecycle(
                        "Test summary: ${result.testCount} tests, " +
                        "${result.successfulTestCount} succeeded, " +
                        "${result.failedTestCount} failed, " +
                        "${result.skippedTestCount} skipped")
                failedTests.takeIf { it.isNotEmpty() }?.prefixedSummary("\tFailed Tests")
                skippedTests.takeIf { it.isNotEmpty() }?.prefixedSummary("\tSkipped Tests:")
            }
        }

        private infix fun List<TestDescriptor>.prefixedSummary(subject: String) {
                logger.lifecycle(subject)
                forEach { test -> logger.lifecycle("\t\t${test.displayName()}") }
        }

        private fun TestDescriptor.displayName() = parent?.let { "${it.name} - $name" } ?: "$name"
    })
}

// cleanTest task doesn't have an accessor on tasks (when this blog post was written)
tasks.named("cleanTest") { group = "verification" }

Gradle test sample output

Running ./gradlew clean build on a sample project, we can see that our Gradle project is working, hopefully we won’t get any checkstyle or spotless violations and all test will be green.

What is the output if some tests are disabled or failing?

See this little cropped example:

...

> Task :test

Error!
org.opentest4j.AssertionFailedError: Error!
	at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:43)
	at org.junit.jupiter.api.Assertions.fail(Assertions.java:129)
	at org.junit.jupiter.api.AssertionsKt.fail(Assertions.kt:24)
	at org.junit.jupiter.api.AssertionsKt.fail$default(Assertions.kt:23)
	at org.lastminute.blog.examples.gradlekts.UnitTest.always failing test()(UnitTest.kt:24)
    ...


org.lastminute.blog.examples.gradlekts.UnitTest > always failing test() FAILED
    org.opentest4j.AssertionFailedError: Error!
        at org.junit.jupiter.api.AssertionUtils.fail(AssertionUtils.java:43)
        at org.junit.jupiter.api.Assertions.fail(Assertions.java:129)
        at org.junit.jupiter.api.AssertionsKt.fail(Assertions.kt:24)
        at org.junit.jupiter.api.AssertionsKt.fail$default(Assertions.kt:23)
        at org.lastminute.blog.examples.gradlekts.UnitTest.always failing test()(UnitTest.kt:24)
org.lastminute.blog.examples.gradlekts.UnitTest > disabled test() SKIPPED
org.lastminute.blog.examples.gradlekts.UnitTest > always passing test() PASSED
----
Test result: FAILURE
Test summary: 3 tests, 1 succeeded, 1 failed, 1 skipped
	Failed Tests
		org.lastminute.blog.examples.gradlekts.UnitTest - always failing test()
	Skipped Tests:
		org.lastminute.blog.examples.gradlekts.UnitTest - disabled test()
1 test completed, 1 failed, 1 skipped
> Task :test FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
...

Conclusion

Gradle’s Kotlin DSL comes with a change of mindset when writing your build script. Kotlin is a type safe language which means that we need to be more strict now on our scripts, specially when migrating Groovy ones.

It is really great to be able to use Kotlin in our build scripts, since we can easily extend or implement new Tasks with few lines of code. Some engineers fear build scripts, specially if they are not comfortable with the language used, and having the opportunity to use the very same language you use everyday on your build scripts lowers the entry barrier for them.

Even JUnit 5 has a nice console launcher for building reports (on CI or manually), we engineers like to see a summary of what went wrong and we were missing that option with the new JUnit Engine on Gradle. Fortunately, with this entry we have improved our initial scripts and been able not only to summarize how the Test task was but also display which tests failed (or were skipped)


staff

We are the European Travel Tech leaders in Dynamic Holiday Packages. We leverage our Technology to simplify, personalise, and enhance customers’ travel experience.


Read next

React Universe 2024

React Universe 2024

fabrizio_duroni
fabrizio duroni
sam_campisi
sam campisi

Let's dive into the talks from React Universe 2024 that stood out to us the most and share the key insights we gained. From innovative debugging tools to cross-platform development strategies, we’ll walk you through what we found valuable and how it’s shaping our approach to React and React Native development. [...]

Tech Radar As a Collaboration Tool

Tech Radar As a Collaboration Tool

rabbani_kajamohideen
rabbani kajamohideen

A tech radar is a visual and strategic tool used by organizations to assess and communicate the status and future direction of various technologies, frameworks, tools, and platforms. [...]