Unit testing made simple: TestDataBuilders with Kotlin

The basic principle of unit testing is that isolated real units of code are tested by mocking the behaviour that surrounds that real unit. For example, a class that receives dependencies through it’s constructor can be isolated by calling the constructor with mocks rather than real dependencies. The real interactions with those mocked dependencies can then be verified in our tests.

When your real unit of code uses a data class it can be easy to assume that the data class must be mocked to isolate the code you want to test. While this is an option, mocking data classes is often overkill which leads to code duplication and test data duplication. Let’s take a look at an alternative.

The builder pattern is a creational design pattern which, amongst other uses, allows constructor calls to be simplified. This is the use of the builder pattern which we are going to use for TestDataBuilders.

// Our data object we want to build
data class House(
val name: String,
val numberOfBricks: Int,
val numberOfRooms: Int
)

// Our Builder
class BobTheBuilder {
var name: String = "A Cosy House"
var numberOfBricks: Int = 10000
var numberOfRooms: Int = 8

fun withName(name: String): BobTheBuilder {
this.name = name
return this
}

fun withBricks(numberOfBricks: Int): BobTheBuilder {
this.numberOfBricks = numberOfBricks
return this
}

fun withNumberOfRooms(completionDate: Int): BobTheBuilder {
this.numberOfRooms = completionDate
return this
}

fun build(): House {
return House(
name,
numberOfBricks,
numberOfRooms
)
}
}

// Calling the builder
fun makeMyHouse() {
BobTheBuilder()
.withName("My Cosy House")
.withNumberOfRooms(5)
.build()
}

In Kotlin, this simple use case of the builder pattern can be simplified greatly by using named parameters along with default parameters.

// A simplified builder in kotlin
fun myCosyHouse(
name: String = "A Cosy House",
numberOfBricks: Int = 10000,
numberOfRooms: Int = 8
): House {
return House(
name,
numberOfBricks,
numberOfRooms
)
}

// Calling the simplified builder with named parameters
fun makeMyHouse() {
myCosyHouse(
name = "My Cosy House",
numberOfRooms = 5
)
}

Great, but why are TestDataBuilders useful in tests? Lets imagine we want to test this simple unit of code.

fun convertHouseToMansion(house: House): Mansion {
val newName = "Huge ${house.name}"
val newBricks = 50000
val additionalRooms = 20
return Mansion(
newName,
house.numberOfBricks + newBricks,
house.numberOfRooms + additionalRooms,
true,
true,
true
)
}
data class Mansion(
val name: String,
var numberOfBricks: Int,
val numberOfRooms: Int,
val basementCinema: Boolean,
val outdoorPool: Boolean,
val poolHouse: Boolean
)

If we had mocked our data class, at this point we would have to define data to return for house name, numberOfBricks, and numberOfRooms.. With TestDataBuilders, we can do this.

// Remember that your TestDataBuilders are test code
// And should therefore live in the test package.
fun aHouse(
name: String = "A Cosy House",
numberOfBricks: Int = 10000,
numberOfRooms: Int = 8
): House {
return House(
name,
numberOfBricks,
numberOfRooms
)
}
// Again, this builder should live in the test package
fun aMansion(
name: String = "A Huge Mansion",
numberOfBricks: Int = 70000,
numberOfRooms: Int = 50,
basementCinema: Boolean = true,
outdoorPool: Boolean = true,
poolHouse: Boolean = true
): Mansion {
return Mansion(
name,
numberOfBricks,
numberOfRooms,
basementCinema,
outdoorPool,
poolHouse
)
}
@Test
fun givenHouse_whenConvert_returnCorrectMansion() {
val house = aHouse()
val expectedResult = aMansion(
name = "Massive ${house.name}",
numberOfBricks = house.numberOfBricks + 50000,
numberOfRooms = house.numberOfRooms + 20
)

val result = convertHouseToMansion(house)

assertThat(result).isEqualTo(expectedResult)
}

Your test is kept simple and readable, your test data is defined only once, and your tests become more maintainable.

Happy testing!

Bonus Points

  • Perhaps you want multiple configurations of the same object. Simple, create another builder that makes use of the first builder.
fun aMansionWithoutPool(): Mansion {
return aMansion(
outdoorPool = false,
poolHouse = false
)
}
  • Perhaps you’re working with a multi-module project and you don’t want to duplicate your TestDataBuilders across modules. Create an Entities module and a CommonTesting module. The CommonTesting module imports the Entities module. Define your common test code (including TestDataBuilders) in the main package of CommonTesting. Now any module can import CommonTesting, along with your TestDataBuilders, as a test dependency.