Writing Parameterized Tests With Kotlin and JUnit 5, Part Two

After we have finished this webinar, we

  • Can use the argument conversion mechanisms provided by JUnit 5.
  • Know how we can aggregate the function parameters of our parameterized test functions.
  • Understand how we can use parameterized tests when we are writing tests for real-life applications.

Let’s start by converting our test data into different objects.

Get the source code from Github

Converting Our Test Data Into Different Objects

JUnit 5 has a very versatile support for argument conversion. To be more specific, it supports the widening primitive conversion and has a number of built-in implicit type converters which can convert String objects into objects which are passed to our parameterized test function. The used type converter depends on the declared type of each function parameter.

Because the JUnit 5 user guide provides an excellent documentation of the implicit type conversion process, I won’t describe it during this webinar.

Additional Reading:

Next, we will find out how we can create the function parameter of our parameterized test by using either a factory function or a factory constructor.

Using the Fallback String-to-Object Conversion

Junit 5 has a mechanism which provides an automatic conversion from a String object to the requested target type. We can use this mechanism if the target type has exactly one suitable factory function or a factory constructor.

Let’s take a look at two examples which demonstrate how we can use this mechanism.

Example 1: Using a Factory Function

If we want to create our function parameter by using a factory function, the target type must have a non-private and static function that takes one String object as a function parameter and returns an instance of the target type.

For example, if we want to pass Book objects to our parameterized test function, the source code of the Book class must look as follows:

 1class Book {
 2
 3    companion object {
 4
 5        @JvmStatic
 6        fun fromTitle(title: String): Book {
 7            return Book().apply {
 8                this.title = title
 9            }
10        }
11    }
12
13    lateinit var title: String
14}

We can now write our parameterized test function by following these steps:

  1. Add a new parameterized test function to our test class and ensure that it takes one Book object as a function parameter.
  2. Specify our test data.
  3. Ensure that the Book object given as a function parameter has the correct title.

After we have written our parameterized test, the source code of our test class looks as follows:

 1import org.assertj.core.api.Assertions.assertThat
 2import org.junit.jupiter.api.DisplayName
 3import org.junit.jupiter.api.Tag
 4import org.junit.jupiter.params.ParameterizedTest
 5import org.junit.jupiter.params.provider.ValueSource
 6
 7@Tag("unitTest")
 8@DisplayName("Factory function conversion example")
 9class FactoryFunctionConversionExampleTest {
10
11    @ParameterizedTest
12    @DisplayName("Should use the factory function")
13    @ValueSource(strings = ["Moby Dick"])
14    fun shouldCreateFunctionParameterWithFactoryFunction(book: Book) {
15        assertThat(book.title).isEqualTo("Moby Dick")
16    }
17}

Example 2: Using a Factory Constructor

If we want to create our function parameter by using a factory constructor, the target type must have a non-private constructor that takes one String object as a constructor argument.

For example, if we want to pass Movie objects to our parameterized test function, the source code of the Movie class must look as follows:

1data class Movie(val title: String)

We can now write our parameterized test function by following these steps:

  1. Add a new parameterized test function to our test class and ensure that it takes one Movie object as a function parameter.
  2. Specify our test data.
  3. Ensure that the Movie object given as a function parameter has the correct title.

After we have written our parameterized test, the source code of our test class looks as follows:

 1import org.assertj.core.api.Assertions.assertThat
 2import org.junit.jupiter.api.DisplayName
 3import org.junit.jupiter.api.Tag
 4import org.junit.jupiter.params.ParameterizedTest
 5import org.junit.jupiter.params.provider.ValueSource
 6
 7@Tag("unitTest")
 8@DisplayName("Factory constructor conversion example")
 9class FactoryConstructorConversionExampleTest {
10
11    @ParameterizedTest
12    @DisplayName("Should use the factory constructor")
13    @ValueSource(strings = ["The Godfather"])
14    fun shouldCreateFunctionParameterWithFactoryConstructor(movie: Movie) {
15        assertThat(movie.title).isEqualTo("The Godfather")
16    }
17}

There are two things I want to point out:

  • If the target type has multiple factory functions, these factory functions will be ignored by JUnit 5.
  • If the target type has a factory function and a factory constructor, JUnit 5 will use the factory function instead of the constructor.

Additional Reading:

Let’s move on and find out how we can use a custom ArgumentConverter.

Using a Custom ArgumentConverter

If we cannot use the built-in argument conversion mechanisms provided by JUnit 5 and we have to convert one String object into another object, we can always implement a custom ArgumentConverter and create the function parameter of our parameterized test function by using our new ArgumentConverter.

Let’s assume that we want to pass Album objects to our parameterized test function. The source code of the Album class looks as follows:

1data class Album(val title: String)

If we want to use a custom ArgumentConverter, we have to follow these steps:

First, we have to implement a custom ArgumentConverter by following these steps:

  1. Create a new class that extends the SimpleArgumentConverter class.
  2. Override the convert() function of the SimpleArgumentConverter class.
  3. Implement the convert() function by returning a new Album object. Also, if the target type is not Album, throw a new IllegalArgumentException.

After we have written our custom argument converter, its source code looks as follows:

 1import org.junit.jupiter.params.converter.SimpleArgumentConverter
 2import java.lang.IllegalArgumentException
 3
 4class AlbumArgumentConverter: SimpleArgumentConverter() {
 5
 6    override fun convert(source: Any?, targetType: Class<*>?): Any {
 7        if (targetType != Album::class.java)
 8            throw IllegalArgumentException("Unknown target type")
 9
10        return Album(source.toString())
11    }
12}

Second, We have to write our parameterized test function by following these steps:

  1. Add a new parameterized test function to our test class and ensure that it takes one Album object as a function parameter.
  2. Ensure that the Album object is created by the AlbumArgumentConverter class.
  3. Specify our test data.
  4. Ensure that the Album object given as a function parameter has the correct title.

After we have written our parameterized test, the source code of our test class looks as follows:

 1import org.assertj.core.api.Assertions.assertThat
 2import org.junit.jupiter.api.DisplayName
 3import org.junit.jupiter.api.Tag
 4import org.junit.jupiter.params.ParameterizedTest
 5import org.junit.jupiter.params.converter.ConvertWith
 6import org.junit.jupiter.params.provider.ValueSource
 7
 8@Tag("unitTest")
 9@DisplayName("Argument converter example")
10class ArgumentConverterExampleTest {
11
12    @ParameterizedTest
13    @DisplayName("Should use the specified argument converter")
14    @ValueSource(strings = ["Yellow Submarine"])
15    fun shouldCreateFunctionParameterWithArgumentConverter(
16            @ConvertWith(AlbumArgumentConverter::class) album: Album
17    ) {
18        assertThat(album.title).isEqualTo("Yellow Submarine")
19    }
20}

We are now familiar with the different argument conversion mechanisms provided by JUnit 5. Next, we will learn to aggregate the function parameters of our parameterized test functions.

Aggregating Multiple Arguments Into One Object

If we use an argument source that supports multiple arguments, the default behavior of JUnit 5 assumes that one argument provided to our parameterized test function corresponds to one function parameter. In other words, if we provide a large number of arguments to our test function, its signature becomes large as well.

We can solve this problem by using the ArgumentsAccessor class or writing a custom ArgumentsAggregator. Let’s move on and find out how we can use these techniques.

Example 1: Using the ArgumentsAccessor Class

If we want to use the ArgumentsAccessor class, we have to write our parameterized test by following these steps:

  1. Add a new parameterized test function to our test class and ensure that it takes ArgumentsAccessor object as a function parameter.
  2. Configure the test data.
  3. Create a new Person object by using the ArgumentsAccessor object given as a function parameter.
  4. Ensure that the created Person object has the correct id and name.

After we have written our parameterized test, the source code of our test class looks as follows:

 1import org.assertj.core.api.Assertions.assertThat
 2import org.junit.jupiter.api.DisplayName
 3import org.junit.jupiter.api.Tag
 4import org.junit.jupiter.params.ParameterizedTest
 5import org.junit.jupiter.params.aggregator.ArgumentsAccessor
 6import org.junit.jupiter.params.provider.CsvSource
 7
 8@Tag("unitTest")
 9@DisplayName("ArgumentsAccessor example")
10class ArgumentsAccessorExampleTest {
11
12    @ParameterizedTest
13    @DisplayName("Should use the ArgumentsAccessor")
14    @CsvSource("1, John Doe")
15    fun shouldUseArgumentsAccessor(arguments: ArgumentsAccessor) {
16        val person = Person(arguments.getLong(0), arguments.getString(1))
17
18        assertThat(person.id).isEqualByComparingTo(1L)
19        assertThat(person.name).isEqualTo("John Doe")
20    }
21}

The Person class is a simple data class that contains the id and name of a person. Its source code looks as follows:

1data class Person (val id: Long, val name: String)

There are three things I want to point out:

  • The ArgumentsAccessor class uses a zero-based index.
  • The ArgumentsAccessor class supports the built-in argument conversion mechanisms provided by JUnit 5.
  • The ArgumentsAccessor class is a good choice if we simply want to reduce the number of function parameters and we don’t want to convert our test data into a specific object (such as a Person object).

Additional Reading:

Example 1: Using a Custom ArgumentsAggregator

If we want to provide Person objects to our parameterized test function by using a custom ArgumentsAggregator, we have to follow these steps:

First, we have to create a custom ArgumentsAggregator class by following these steps:

  1. Create a class that implements the ArgumentsAggregator interface.
  2. override the aggregateArguments() function.
  3. Implement the aggregateArguments() function by returning a new Person object. Also, if the number of arguments is not two, throw a new IllegalArgumentException.

After we have written our custom ArgumentsAggregator class, its source code looks as follows:

 1import org.junit.jupiter.api.extension.ParameterContext
 2import org.junit.jupiter.params.aggregator.ArgumentsAccessor
 3import org.junit.jupiter.params.aggregator.ArgumentsAggregator
 4import java.lang.IllegalArgumentException
 5
 6class PersonAggregator: ArgumentsAggregator {
 7
 8    override fun aggregateArguments(arguments: ArgumentsAccessor,
 9                                    context: ParameterContext): Any {
10        if (arguments.size() != 2)
11            throw IllegalArgumentException("Unsupported argument count")
12        return Person(arguments.getLong(0), arguments.getString(1))
13    }
14}

Second, we have to write our parameterized test function by following these steps:

  1. Add a new parameterized test function to our test class and ensure that it takes a Person object as a function parameter.
  2. Ensure that the Person object is created by the PersonAggregator class.
  3. Specify our test data.
  4. Ensure that the Person object given as a method parameter has the correct id and name.

After we have written our parameterized test function, its source code looks as follows:

 1import com.testspringmaster.kotlin.example.Person
 2import org.assertj.core.api.Assertions.assertThat
 3import org.junit.jupiter.api.DisplayName
 4import org.junit.jupiter.api.Tag
 5import org.junit.jupiter.params.ParameterizedTest
 6import org.junit.jupiter.params.aggregator.AggregateWith
 7import org.junit.jupiter.params.provider.CsvSource
 8
 9@Tag("unitTest")
10@DisplayName("PersonAggregator example")
11class PersonAggregatorExampleTest {
12
13    @ParameterizedTest
14    @DisplayName("Should pass a Person object as function parameter")
15    @CsvSource("1, John Doe")
16    fun shouldPassPersonObjectAsFunctionParameter(
17            @AggregateWith(PersonAggregator::class) person: Person
18    ) {
19        assertThat(person.id).isEqualByComparingTo(1L)
20        assertThat(person.name).isEqualTo("John Doe")
21    }
22}

We can now aggregate the function parameters of our parameterized tests. Let’s move on and find out how we can leverage parameterized tests when we are writing tests for real-life applications.

Parameterized Tests - Beyond the Basics

I haven’t seen a lot of parameterized tests at work, and unfortunately I have to confess that I don’t write them either. This is a shame because parameterized testing is a useful tool when we are writing tests for finder methods.

For example, let’s assume that we have to write tests for the PersonRepository class. This class has one method called findById() that returns the information of the found person. If no person is found with the given id, this method throws a NotFoundException.

Next, we will learn to leverage parameterized tests when we are writing tests for the PersonRepository class. When we want to write tests for the findById() method, we have to follow these steps:

First, we have add a new inner class to our test class. This class contains the test methods which ensure that the findById() is working as expected.

After we have added this inner class to our test class, the source code of our test class looks as follows:

 1import org.junit.jupiter.api.DisplayName
 2import org.junit.jupiter.api.Nested
 3import org.junit.jupiter.api.Tag
 4
 5@Tag("unitTest")
 6@DisplayName("A parameterized test for a finder method")
 7class PersonRepositoryTest{
 8
 9    private val repository = PersonRepository()
10
11    @Nested
12    @DisplayName("Find person by id")
13    inner class FindById {
14
15    }
16}

Second, we have to write a test method which ensures that the findById() method throws a NotFoundException when the requested person is not found.

After we have written this test method, the source code of our test class looks as follows:

 1import org.assertj.core.api.Assertions.assertThat
 2import org.assertj.core.api.Assertions.catchThrowable
 3import org.junit.jupiter.api.DisplayName
 4import org.junit.jupiter.api.Nested
 5import org.junit.jupiter.api.Tag
 6import org.junit.jupiter.api.Test
 7
 8@Tag("unitTest")
 9@DisplayName("A parameterized test for a finder method")
10class PersonRepositoryTest{
11
12    private val repository = PersonRepository()
13
14    @Nested
15    @DisplayName("Find person by id")
16    inner class FindById {
17
18        @Nested
19        @DisplayName("When the person is not found")
20        inner class WhenPersonIsNotFound {
21
22            private val ID_NOT_FOUND = 999L
23
24            @Test
25            @DisplayName("Should throw an exception")
26            fun shouldThrowException() {
27                val thrown = catchThrowable {
28                    repository.findById(ID_NOT_FOUND)
29                }
30                assertThat(thrown)
31                        .isExactlyInstanceOf(NotFoundException::class.java)
32            }
33        }
34    }
35}

Third, we have to write a parameterized test which ensures that the findById() method returns the information of the found person.

After we have written this parameterized test, the source code of our test class looks as follows:

 1import com.testwithspring.master.kotlin.aggregation.PersonAggregator
 2import org.assertj.core.api.Assertions.assertThat
 3import org.assertj.core.api.Assertions.catchThrowable
 4import org.junit.jupiter.api.DisplayName
 5import org.junit.jupiter.api.Nested
 6import org.junit.jupiter.api.Tag
 7import org.junit.jupiter.api.Test
 8import org.junit.jupiter.params.ParameterizedTest
 9import org.junit.jupiter.params.aggregator.AggregateWith
10import org.junit.jupiter.params.provider.CsvSource
11
12@Tag("unitTest")
13@DisplayName("A parameterized test for a finder method")
14class PersonRepositoryTest{
15
16    private val repository = PersonRepository()
17
18    @Nested
19    @DisplayName("Find person by id")
20    inner class FindById {
21
22        @Nested
23        @DisplayName("When the person is not found")
24        inner class WhenPersonIsNotFound {
25
26            private val ID_NOT_FOUND = 999L
27
28            @Test
29            @DisplayName("Should throw an exception")
30            fun shouldThrowException() {
31                val thrown = catchThrowable {
32                    repository.findById(ID_NOT_FOUND)
33                }
34                assertThat(thrown)
35                        .isExactlyInstanceOf(NotFoundException::class.java)
36            }
37        }
38
39        @Nested
40        @DisplayName("When the person is found")
41        inner class WhenPersonIsFound {
42
43            @ParameterizedTest
44            @DisplayName("Should return the found person")
45            @CsvSource(
46                    "1, John Doe",
47                    "2, Jane Doe"
48            )
49            fun shouldReturnFoundPerson(
50                    @AggregateWith(PersonAggregator::class) person: Person
51            ) {
52                val found = repository.findById(person.id)
53
54                assertThat(found.id).isEqualByComparingTo(person.id)
55                assertThat(found.name).isEqualTo(person.name)
56            }
57        }
58    }
59}

As we can see, if we combine parameterized tests with normal test methods, it’s quite easy to write clean and comprehensive tests for finder methods. This approach is especially useful if the system under test uses complex mapping logic that creates the returned object, and we want to write tests for that logic.

Naturally, if we use parameterized tests for this purpose, it’s a good idea to use soft assertions because soft assertions allow us to gather all assertion errors and display these errors when a test case fails.

Also, even though this example invokes a hard-coded repository, we can use this technique when we are writing integration tests with the Spring Test Framework.

Let’s summarize what we learned from this webinar.

Summary

This webinar has taught us four things:

  • Junit 5 has a mechanism which provides an automatic conversion from a String object to the requested target type if the target type has exactly one suitable factory function or a factory constructor.
  • If we cannot use the built-in argument conversion mechanisms provided by JUnit 5 and we have to convert one String object into another object, we can always implement a custom ArgumentConverter.
  • If we want to combile multiple arguments into one function parameter, we have to use the ArgumentsAccessor class or implement a custom ArgumentsAggregator.
  • If we combine parameterized tests with normal test methods, it’s quite easy to write clean and comprehensive tests for finder methods.

Get the source code from Github