1. Introduction

The newly released JUnit 5 is the next version of the well-known testing framework for Java. This version includes a number of features that specifically target functionality introduced in Java 8 — it’s primarily built around the use of lambda expressions.

In this quick article, we’ll show how well the same tool works with the Kotlin language.

2. Simple JUnit 5 Tests

At its very simplest, a JUnit 5 test written in Kotlin works exactly as would be expected. We write a test class, annotate our test methods with the @Test annotation, write our code, and perform the assertions:

class CalculatorTest {
    private val calculator = Calculator()

    @Test
    fun whenAdding1and3_thenAnswerIs4() {
        Assertions.assertEquals(4, calculator.add(1, 3))
    }
}

Everything here just works out of the box. We can make use of the standard @Test, @BeforeAll, @BeforeEach, @AfterEach, and @AfterAll annotations. We can also interact with fields in the test class exactly the same as in Java.

Note that the imports required are different, and we do assertions using the Assertions class instead of the Assert class. This is a standard change for JUnit 5 and is not specific to Kotlin.

Before going any further, let’s change the test name and use b****acktick identifiers in Kotlin:

@Test
fun `Adding 1 and 3 should be equal to 4`() {
    Assertions.assertEquals(4, calculator.add(1, 3))
}

Now it’s much more readable! In Kotlin, we can declare all variables and functions using backticks, but it’s not recommended to do so for normal use cases.

3. Advanced Assertions

JUnit 5 adds some advanced assertions for working with lambdas. These work the same in Kotlin as in Java but need to be expressed in a slightly different way due to the way the language works.

3.1. Asserting Exceptions

JUnit 5 adds an assertion for when a call is expected to throw an exception. We can test that a specific call — rather than just any call in the method — throws the expected exception. We can even assert on the exception itself.

In Java, we’d pass a lambda into the call to Assertions.assertThrows. We do the same in Kotlin, but we can make the code more readable by appending a block to the end of the assertion call:

@Test
fun `Dividing by zero should throw the DivideByZeroException`() {
    val exception = Assertions.assertThrows(DivideByZeroException::class.java) {
        calculator.divide(5, 0)
    }

    Assertions.assertEquals(5, exception.numerator)
}

This code works exactly the same as the Java equivalent but is easier to read since we don’t need to pass a lambda inside of the brackets where we call the assertThrows function.

3.2. Multiple Assertions

JUnit 5 adds the ability to perform multiple assertions at the same time, and it’ll evaluate them all and report on all of the failures.

This allows us to gather more information in a single test run rather than being forced to fix one error only to hit the next one. To do so, we call Assertions.assertAll, passing in an arbitrary number of lambdas.

In Kotlin, we need to handle this slightly differently. The function actually takes a varargs parameter of type Executable.

At present, there’s no support for automatically casting a lambda to a functional interface, so we need to do it by hand:

fun `The square of a number should be equal to that number multiplied in itself`() {
    Assertions.assertAll(
        Executable { Assertions.assertEquals(1, calculator.square(1)) },
        Executable { Assertions.assertEquals(4, calculator.square(2)) },
        Executable { Assertions.assertEquals(9, calculator.square(3)) }
    )
}

3.3. Suppliers for True and False Tests

On occasion, we want to test that some call returns a true or false value. Historically we would compute this value and call assertTrue or assertFalse as appropriate. JUnit 5 allows for a lambda to be provided instead that returns the value being checked.

Kotlin allows us to pass in a lambda in the same way that we saw above for testing exceptions. We can also pass in method references. This is especially useful when testing the return value of some existing object like we do here using List.isEmpty:

@Test
fun `isEmpty should return true for empty lists`() {
    val list = listOf<String>()
    Assertions.assertTrue(list::isEmpty)
}

3.4. Suppliers for Failure Messages

In some cases, we want to provide our own error message to be displayed on an assertion failure instead of the default one.

Often these are simple strings, but sometimes we may want to use a string that is expensive to compute. In JUnit 5, we can provide a lambda to compute this string, and it is only called on failure instead of being computed upfront.

This can help make the tests run faster and reduce build times. This works exactly the same as we saw before:

@Test
fun `3 is equal to 4`() {
    Assertions.assertEquals(3, 4) {
        "Three does not equal four"
    }
}

4. Data-Driven Tests

One of the big improvements in JUnit 5 is the native support for data-driven tests. These work equally well in Kotlin, and the use of functional mappings on collections can make our tests easier to read and maintain.

4.1. TestFactory Methods

The easiest way to handle data-driven tests is by using the @TestFactory annotation. This replaces the @Test annotation, and the method returns some collection of DynamicNode instances — normally created by calling DynamicTest.dynamicTest.

This works exactly the same in Kotlin, and we can pass in the lambda in a cleaner way again, as we saw earlier:

@TestFactory
fun testSquares() = listOf(
    DynamicTest.dynamicTest("when I calculate 1^2 then I get 1") { Assertions.assertEquals(1,calculator.square(1))},
    DynamicTest.dynamicTest("when I calculate 2^2 then I get 4") { Assertions.assertEquals(4,calculator.square(2))},
    DynamicTest.dynamicTest("when I calculate 3^2 then I get 9") { Assertions.assertEquals(9,calculator.square(3))}
)

We can do better than this though. We can easily build our list by performing some functional mapping on a simple input list of data:

@TestFactory
fun testSquares() = listOf(
    1 to 1,
    2 to 4,
    3 to 9,
    4 to 16,
    5 to 25)
    .map { (input, expected) ->
        DynamicTest.dynamicTest("when I calculate $input^2 then I get $expected") {
            Assertions.assertEquals(expected, calculator.square(input))
        }
    }

Straight away, we can easily add more test cases to the input list, and it will automatically add tests.

We can also create the input list as a class field and share it between multiple tests:

private val squaresTestData = listOf(
    1 to 1,
    2 to 4,
    3 to 9,
    4 to 16,
    5 to 25)
@TestFactory
fun testSquares() = squaresTestData
    .map { (input, expected) ->
        DynamicTest.dynamicTest("when I calculate $input^2 then I get $expected") {
            Assertions.assertEquals(expected, calculator.square(input))
        }
    }
@TestFactory
fun testSquareRoots() = squaresTestData
    .map { (expected, input) ->
        DynamicTest.dynamicTest("when I calculate the square root of $input then I get $expected") {
            Assertions.assertEquals(expected, calculator.squareRoot(input))
        }
    }

4.2. Parameterized Tests

There are experimental extensions to JUnit 5 to allow easier ways to write parameterized tests. These are done using the @ParameterizedTest annotation from the org.junit.jupiter:junit-jupiter-params dependency:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.0.0</version>
</dependency>

The latest version can be found on Maven Central.

The @MethodSource annotation allows us to produce test parameters by calling a static function that resides in the same class as the test. This is possible but not obvious in Kotlin. We have to use the @JvmStatic annotation inside a companion object:

@ParameterizedTest
@MethodSource("squares")
fun testSquares(input: Int, expected: Int) {
    Assertions.assertEquals(expected, input * input)
}

companion object {
    @JvmStatic
    fun squares() = listOf(
        Arguments.of(1, 1),
        Arguments.of(2, 4)
    )
}

This also means that the methods used to produce parameters must all be together since we can only have a single companion object per class.

All of the other ways of using parameterized tests work exactly the same in Kotlin as they do in Java. @CsvSource is of special note here, since we can use that instead of @MethodSource for simple test data most of the time to make our tests more readable:

@ParameterizedTest
@CsvSource(
    "1, 1",
    "2, 4",
    "3, 9"
)
fun testSquares(input: Int, expected: Int) {
    Assertions.assertEquals(expected, input * input)
}

5. Tagged Tests

The Kotlin language since version 1.6 does allow for repeatable annotations on classes and methods. This makes the use of tags slightly less verbose, as we are not required to wrap them in the @Tags annotation. However we are required to mark @Tag as @java.lang.annotation.Repeatable in the bytecode, so that it’s repeatable in Java as well:

// JVM bytecode: @Tag.Container(value = {@Tag("slow"), @Tag("logarithms")})
@Tag("slow") @Tag("logarithms")
@Test
fun `Repeatable Tag Log to base 2 of 8 should be equal to 3`() {
    Assertions.assertEquals(3.0, calculator.log(2, 8))
}

This is also required in Java 7 and is fully supported by JUnit 5 already.

6. Summary

JUnit 5 adds some powerful testing tools that we can use. These almost all work perfectly well with the Kotlin language, though in some cases with slightly different syntax than we see in the Java equivalents.

Often though, these changes in syntax are easier to read and work with when using Kotlin.

Examples of all these features can be found over on GitHub.


« 上一篇: Kotlin中的扩展函数
» 下一篇: 从Java迁移到Kotlin