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.
This webinar assumes that:
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:
- Add a new parameterized test function to our test class and ensure that it takes one
Bookobject as a function parameter. - Specify our test data.
- Ensure that the
Bookobject 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:
- Add a new parameterized test function to our test class and ensure that it takes one
Movieobject as a function parameter. - Specify our test data.
- Ensure that the
Movieobject 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:
- Create a new class that extends the
SimpleArgumentConverterclass. - Override the
convert()function of theSimpleArgumentConverterclass. - Implement the
convert()function by returning a newAlbumobject. Also, if the target type is notAlbum, throw a newIllegalArgumentException.
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:
- Add a new parameterized test function to our test class and ensure that it takes one
Albumobject as a function parameter. - Ensure that the
Albumobject is created by theAlbumArgumentConverterclass. - Specify our test data.
- Ensure that the
Albumobject 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}
Additional Reading:
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:
- Add a new parameterized test function to our test class and ensure that it takes
ArgumentsAccessorobject as a function parameter. - Configure the test data.
- Create a new
Personobject by using theArgumentsAccessorobject given as a function parameter. - Ensure that the created
Personobject has the correctidandname.
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
ArgumentsAccessorclass uses a zero-based index. - The
ArgumentsAccessorclass supports the built-in argument conversion mechanisms provided by JUnit 5. - The
ArgumentsAccessorclass 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 aPersonobject).
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:
- Create a class that implements the
ArgumentsAggregatorinterface. - override the
aggregateArguments()function. - Implement the
aggregateArguments()function by returning a newPersonobject. Also, if the number of arguments is not two, throw a newIllegalArgumentException.
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:
- Add a new parameterized test function to our test class and ensure that it takes a
Personobject as a function parameter. - Ensure that the
Personobject is created by thePersonAggregatorclass. - Specify our test data.
- Ensure that the
Personobject given as a method parameter has the correctidandname.
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}
Additional Reading:
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
Stringobject 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
Stringobject into another object, we can always implement a customArgumentConverter. - If we want to combile multiple arguments into one function parameter, we have to use the
ArgumentsAccessorclass or implement a customArgumentsAggregator. - If we combine parameterized tests with normal test methods, it’s quite easy to write clean and comprehensive tests for finder methods.