Writing Test Data Builders Made Easy With Kotlin


In this article I will be focusing on few features of Kotlin that help in writing concise and readable code. To explain the features, I will be using the concept of Test Data Builders, as described in the book Growing Object-Oriented Software, Guided by Tests. I hope that by the end of this article I will have generated enough interest in you to try out Kotlin.

If you are not aware, Kotlin, developed by JetBrains, is a statically typed programming language for the JVM. It is fully interoperable with Java (i.e. Kotlin code can be called from Java and vice versa).

I will be focusing on the following features of Kotlin:

Concept of Test Data Builders

When you write a test, unit or integration, you need test data. A domain object may exist in many different states, depending on how it is constructed and modified. These different states of an object generally form the test data. The code for construction and modification of objects has to be written in every test which uses the object. When the object is complex, the code to construct and modify it becomes complex thus reducing the readability and maintainability of the tests.

By using Test Data Builders, which uses Builder Pattern, the construction and modification of a complex object can be made simple. We will see how the usage of builders leads to better readability and maintainability and see how Kotlin makes it easy to write these builders.

Example Object

Before we look at one of these builders, lets see an example of an object for which a test data builder may be useful. This example has been taken from the documentation of make-it-easy, a tiny framework that makes it easy to write the Test Data Builders in Java.

class Apple(private val leaves: Int) {
    private var ripeness = 0.0

    fun ripen(amount: Double) {
        ripeness = Math.min(1.0, ripeness + amount)
    }

    fun isRipe() = ripeness == 1.0
    
    fun numberOfLeaves() = leaves
}

From the code above the following observations can be made:

  • The object in this case in an Apple
  • Number of leaves is defined using a constructor in the first line. Notice how concise the constructor definition is.
  • By default an apple is unripe (i.e. ripeness = 0.0).
  • Ripeness can be modified using the ripen() method.

Examples of test data involving apple can be:

  • An unripe apple with 2 leaves (leaves = 2 and ripeness = 0.0),
  • A ripe apple with 1 leaf (leaves = 1 and ripeness = 1.0), and many more.

Test Data Builder for Apple

Lets define a Builder for the Apple now.

data class AppleBuilder(val ripeness: Double = 0.0, val leaves: Int = 1) {
    fun build() = Apple(leaves).apply { ripen(ripeness) }
}

From the code above the following observations can be made:

  • The builder defines two properties ripeness and leaves in its constructor and both of them have default values.
  • It uses the leaves property to construct an apple by making the call Apple(leaves).
  • It uses the ripeness property to set the appropriate ripeness on the constructed apple using
    apply { ripen(ripeness) }, which is an extension function. I am not covering extension functions in this article. apply is simply calling the { ripen(ripeness) } function on newly created apple.
  • The data keyword in the first line defines the AppleBuilder as a Data Class. I will be explaining it later in the article.

The AppleBuilder can be created in the following ways to construct instances of apples in tests.

Using Default Arguments

val unripeAppleWith1Leaf = AppleBuilder().build()

In the code above, the AppleBuilder is created by not specifying any constructor arguments. Note that this is possible because the arguments in the constructor definition has default values. The instance of builder takes the default values of ripeness and leaves defined on its constructor and thus builds and unripe apple with one leaf. This feature is called Default Arguments in Kotlin. Using the default values in the arguments of functions and constructor definitions obviates the need of specifying the arguments while making the constructor or function calls. The defined default values are assumed when arguments are not specified.

Using Named Arguments

val unripeAppleWith2Leaves = AppleBuilder(leaves = 2).build()

In this case the AppleBuilder instance is created with the customized value of leaves i.e. 2 and the default value of ripeness i.e. 0.0 (an unripe apple with 2 leaves). Note that only one of two constructor arguments is specified in the constructor call. This feature is called Named Arguments in Kotlin. Using the Named Arguments while making function or constructor calls obviates the need of specifying all the arguments defined in the constructor or function definition.

Default Arguments and Named Arguments features of Kotlin help in writing concise code by removing the need overloaded functions and constructors.

One more benefit of Named Arguments is that the code becomes more clear on the client/calling side. Compare AppleBuilder(0.5, 2) with AppleBuilder(ripeness = 0.5, leaves = 2). The latter is definitely more readable. In the former you will have to go to the constructor definition to check what properties 0.5 and 2 and correspond to. Also AppleBuilder(ripeness = 0.5, leaves = 2) is same as AppleBuilder(leaves = 2, ripeness = 0.5). Using the Named Arguments while making function or constructor calls the arguments can be defined in any order irrespective of the order defined in the constructor or function definition.

Named Arguments feature of Kotlin helps in making the code readable.

The features provided by Default Arguments and Named Arguments is achieved in Java using overloaded constructors and methods, which increases a lot of boilerplate on the definition side. Also on the usage side the arguments have to be passed in specific order which makes the code prone to errors (in cases when more than one argument of same type is present, there is a risk on the values getting interchanged).

Using Copies of Pre-defined Builders

Suppose many tests use ripe apples as a common condition in their test data but the number of leaves are different in each test. Defining a builder, which can be re-used by the tests, will be helpful. Each test can then create a copy of this generic builder and specify the test specific parameters, which in this case will be leaves, and build apples out of them.

val ripeAppleBuilder = AppleBuilder(ripeness = 1, leaves = 0)

val ripeAppleWith2Leaves = ripeAppleBuilder.copy(leaves = 2).build()

In the above code, ripeAppleBuilder is the reusable builder which can be used by many tests. Though in this case it seems an overhead to create a reusable builder, it becomes beneficial when builders are complex. The copy() method is provided by the Data Classes. If you look at the definition of AppleBuilder again, it has data class AppleBuilder instead of just class AppleBuilder. The copy() method provides a copy of the reusable builder but with 2 leaves instead of 0. All the other properties remain the same as original builder. Kotlin automatically also generates other functions like equals()/hashcode() and toString() using the properties of data classes.

Data Classes in Kotlin also help in writing concise code by generating few useful functions.

Summary

Default Arguments, Named Arguments and Data Classes are some of many features that Kotlin provide which can be used to write concise and readable code. Kotlin has a potential to be a good addition to your development toolbox.

Related Posts

Spring up an application quickly with Spring Boot

How functional programming helps me write clean code

Using asynchrony to reduce response times in Java 8

Book Review - Soft Skills: The software developer's life manual

Java 8 Optional as a Monad

REST Error Responses in Spring Boot

Writing Comparators - The Java 8 Way

RxJava - Part 3 - Multithreading

RxJava - Part 2 - Creating an Observable

RxJava - Part 1 - A Quick Introduction