Two weeks ago, we entered the world of building domain specific languages in Kotlin. The previous article was rather theoretical. Now it’s time to put the theory into practice. We are going to build a Kotlin DSL for automated tests. The goal is better describing business use cases in the ‘given’ section. Also, why not building a single DSL that we could use both for unit and integration tests? Is it possible? Let’s find out.

tl;dr;

Using a DSL in automatic tests greatly reduces the later maintenance cost. However, it requires building a flexible foundation, because every new feature added to our project will pose new challenges to our DSL.

Problem statement

Let’s take a closer look at the problem.

Why Kotlin DSL for tests?

Many programmers already took a life lesson about unit tests. In many projects, writing them for every single class does not scale well. The tests are too detailed, too tied to the code structure, and they do not cover interactions between classes (which are equally important). Instead, we can treat the whole business logic as a single unit and write tests at this level. All the infrastructure, such as databases, is mocked. This approach requires building a couple of smart, in-memory simulators, and… a way to describe the business use case in tests.

// given
use case context

// when
interaction

// then
outcome

We can describe the use case simply as a series of domain command invocations that configure everything. Unfortunately, we run into another problem. Over time, we add new features. They add more and more configuration steps. But do we remember about including them in all the tests? Probably no. How do we know, if our tests still verify the actual business scenario without calling the new step? This is where I found DSL very useful. I did not want to call the configuration functions manually anymore. I wanted to say: “I have user X, I have thing Y, they do Z, setup that for me”. I want to focus on my business use case, and the test environment should correctly set up it for me.

Sample code

Find a complete example on Github: kotlindsl-testing-demo

Example domain for Kotlin DSL

To show a practical DSL, we need some business domain. I chose a simple home automation system. The building comprises different rooms with monitoring stations. Monitoring stations are connected to sensors, such as thermometers or humidity checkers. There are also devices like fans and heaters. Finally, we have some automation rules which control the devices in response to the sensor readings. They work independently for each room by default.

An example use case could look like this:

  • big room with 2 thermometers, 2 fans and a heater,
  • we want to enable 1 fan, when the temperature is above 25.5°C
  • we want to enable the second fan, when the temperature is above 30.5°C
  • we want to heat the room, when the temperature drops below 20.5°C

Let’s think how to reflect this in the DSL. A good DSL should be self-explanatory, so we should take a declarative approach:

domain.thereIsUseCase {
    room(name = MAIN_HALL) {
        monitoringStation(name = "MS1", address = "https://ms1.test.local") {
            temperatureSensor(name = "T1", address = 1) {
                readings(23, 24, 25, 26, 25, 24)
            }
            temperatureSensor(name = "T2", address = 2) {
                readings(23, 23, 24, 24, 26, 26)
            }
        }
        fan(name = FAN1_DEVICE)
        fan(name = FAN2_DEVICE)
        heater(name = HEATER1_DEVICE)
    }
    rules(
        MaxTemperature(
            threshold = 25.5,
            sensors = allSensors(),
            devices = onlyDevices(FAN1_DEVICE)
        ),
        MaxTemperature(
            threshold = 30.5,
            sensors = allSensors(),
            devices = onlyDevices(FAN2_DEVICE)
        ),
        MinTemperature(
            threshold = 20.5,
            sensors = allSensors(),
            devices = onlyDevices(HEATER1_DEVICE)
        )
    )
}

And this is what we are actually going to implement.

In short…

In the declarative approach, you just tell “what” you want and let the test environment figure out, how to configure it.

Implementing a DSL in Kotlin for tests

Function literals with receiver: foundation of DSL

The basic building blocks of Kotlin syntax for DSL are regular classes and so-called function literals with receiver. A receiver is an object under this keyword within the given function. Every non-static function has one, including lambdas. In a typical lambda, the receiver is always the object which creates it:

class Hello {
    val x = 7
    fun createLambda(): (Int) -> Int = { it + this.x } // this: current instance of 'Hello'
}

fun main() {
    val hello = Hello()
    val lambda = hello.createLambda()
    print(lambda(6)) // prints "13"
}

We can also create functions which accept lambdas as arguments (“high-order functions”). Kotlin offers a special syntax for calling them, by skipping some parentheses:

fun highOrderFunction(lambda: (Int) -> Int): Int {
   return lambda(7) // performs the operation represented by 'lambda' on '7'
}

highOrderFunction { it + 10 }

Now, how about forcing lambda to use a particular object as a receiver (this)? This is exactly what function literals with receiver are:

fun highOrderFunctionWithReceiver(lambda: Hello.(Int) -> Int): Int {
   val hello = Hello()
   return lambda(hello, 7) // 'hello' from line 2 becomes 'this' for lambda
}

highOrderFunctionWithReceiver { it + this.x }

In short…

Function literals with receivers are the basic tool for building domain specific languages in Kotlin.

Decision 1: keep Kotlin DSL interfaces separately

Function literals with receivers help creating those nice-looking declarations in our DSL. Why do we need custom receivers? To call the functions in the lower level without dots. Take a look at the following example:

domain.thereIsUseCase {
    room(name = MAIN_HALL) {
        monitoringStation(name = "MS1", address = "https://ms1.test.local") {
            temperatureSensor(name = "T1", address = 1) {
                readings(23, 24, 25, 26, 25, 24)
            }
        }
    }
}

Here, thereIsUseCase() calls the lambda on some object which offers this.room() function. This is why we can call it inside the brackets. Then, room() calls the next lambda on an object that provides this.monitoringStation() function and so on. The class structure for such a DSL should be simple: a couple of classes with functions that create the domain entities upon calling. Unfortunately given how the domain complexity can grow over time, this design would soon become too limiting. Here we must make the first important decision. The best way to describe the structure of your DSL are interfaces. Don’t bother about the stuff under the hood, just shape your DSL as interfaces to get the correct look and feel:

@AutomationDslMarker
interface AutomationDsl {
    @AutomationDslMarker
    fun room(name: String, dsl: RoomDsl.() -> Unit)

    @AutomationDslMarker
    fun rules(rules: List<MonitoringRule>)

    @AutomationDslMarker
    fun rules(vararg rules: MonitoringRule) = rules(rules.toList())
}

@AutomationDslMarker
interface RoomDsl {
    @AutomationDslMarker
    fun monitoringStation(name: String, address: String, dsl: MonitoringStationDsl.() -> Unit)

    @AutomationDslMarker
    fun fan(name: String)

    @AutomationDslMarker
    fun dehumidifier(name: String)

    @AutomationDslMarker
    fun heater(name: String)
}

@DslMarker
annotation class AutomationDslMarker

Consequences:

  • focus on the look&feel of your DSL
  • the implementation objects may have extra functions which don’t have to be visible at the DSL level,
  • you can have more than one implementation of every interface (think about decorators)
  • it can serve as a documentation for other programmers, because it’s free from technical details.

Decision 2: do not use domain entities directly in DSL

We have the DSL, now we need some backing code. OK, but how should it look like? Should we create thin wrappers around actual domain entities? It’s possible. However, we have said that we want to use the DSL both for unit and integration tests. Domain entities won’t likely be used for making REST API calls. It may also turn out, that they are not used directly by domain commands, too! Therefore, they may not be the best choice for us.

I prefer backing my DSL interfaces with standalone classes called definitions. They collect information about business entities to create, and provide it to the rest of the test environment. I start with a simple 1:1 mapping between the DSL interfaces and definitions:

class AutomationDefinition : AutomationDsl {
    private val roomDefinitions: MutableList<RoomDefinition> = ArrayList()
    private var rules: List<MonitoringRule> = emptyList()

    override fun room(name: String, dsl: RoomDsl.() -> Unit) {
        roomDefinitions += RoomDefinition(name).apply(dsl)
    }

    override fun rules(rules: List<MonitoringRule>) {
        this.rules = ImmutableList.copyOf(rules)
    }

    fun installWith(processor: AutomationStrategy) {
        processor.process(roomDefinitions, rules)
    }
}

class RoomDefinition(val name: String) : RoomDsl {
    private val monitoringStationDefinitions: MutableList<MonitoringStationDefinition>
        = ArrayList()
    private val deviceDefinitions: MutableList<DeviceDefinition> = ArrayList()

    override fun monitoringStation(
        name: String,
        address: String,
        dsl: MonitoringStationDsl.() -> Unit
    ) {
        monitoringStationDefinitions += MonitoringStationDefinition(name, address)
            .apply(dsl)
    }

    override fun fan(name: String) {
        deviceDefinitions += DeviceDefinition(name, FAN)
    }

    override fun dehumidifier(name: String) {
        deviceDefinitions += DeviceDefinition(name, DEHUMIDIFIER)
    }

    override fun heater(name: String) {
        deviceDefinitions += DeviceDefinition(name, HEATER)
    }

    fun fetchMonitoringStations() = ImmutableList.copyOf(monitoringStationDefinitions)

    fun fetchDevices() = ImmutableList.copyOf(deviceDefinitions)
}

This approach has one huge advantage: we can easily add various test-specific stuff to it. A good example is managing timestamps. Juggling absolute timestamps in tests is problematic – we may end up sending the same timestamp for everything, doing the time travel, etc. Instead, we can let the DSL generate some reasonable timestamps for us. But what if we want to access them in assertions? This is where the flexibility of definitions becomes useful. Let’s record every generated timestamp in the definition, so that we can read it in the assertion code, and use in comparing the results.

Another example is building entities from a template. Let’s say we need several user accounts in our scenario. We don’t need anything special from them, they must just exist. Why not creating a userTemplate { } block in our DSL and apply it to every user, whose ID appears in other parts of the scenario? In this approach, we will have two user definitions for our DSL: user from template, and explicitly created user:

interface UserTemplate {
   val name: String
   val registrationDate: Instant
}

interface UserDefinition : UserTemplate {
   val id: UserId
}

data class ExplicitUserDefinition(
    override val id: UserId
    override val name: String
    override val registrationDate: Instant
) : UserDefinition, UserDsl

data class UserFromTemplateDefinition(
    override val id: UserId,
    val template: UserTemplate
) : UserDefinition, UserDsl UserTemplate by template

Decision 3: do not apply the configuration directly

The third decision is partially related to the previous one. We have our definition classes, and we want to translate them to configuration command invocations. Where should we put that code? The naive approach is making a kind of toDomain() function in every definition. However, let’s remember that we wanted to use our Kotlin DSL both in unit and integration tests. In the unit tests, the DSL will configure in-memory repositories and clients. In the integration tests, it will make REST API calls to our service. The naive approach would require us putting both worlds into the definitions, and moreover – keep our integration test environment inside unit tests. Blah!

Instead, let’s just create an AutomationStrategy interface with two implementations: UnitTestAutomationStrategy and IntegrationTestAutomationStrategy:

interface AutomationStrategy {
    fun process(rooms: List<RoomDefinition>, rules: List<MonitoringRule>)
}

class UnitTestAutomationStrategy(
    private val target: ModelDictionary,
    private val client: InMemoryMonitoringStationClient
) : AutomationStrategy {
    override fun process(rooms: List<RoomDefinition>, rules: List<MonitoringRule>) {
        val roomModels = rooms.map {
            Room(
                name = it.name,
                stations = processMonitoringStations(it.fetchMonitoringStations()),
                devices = processDevices(it.fetchDevices())
            )
        }
        val clients = createClients(rooms)

        client.installClients(clients)
        target.installModel(roomModels, rules)
    }

    // ...
}

class AutomationDefinition : AutomationDsl {
    // ...
    fun installWith(processor: AutomationStrategy) {
        processor.process(roomDefinitions, rules)
    }
}

class DomainOperations {
    val dictionary = ModelDictionary()
    val monitoringStationClient = InMemoryMonitoringStationClient()
    val actual = Domain(
        monitoringStationClient = monitoringStationClient,
        roomRepository = dictionary,
        ruleRepository = dictionary
    )

    fun thereIsUseCase(dsl: AutomationDsl.() -> Unit) {
        val definition = AutomationDefinition()
        definition.apply(dsl)
        definition.installWith(
            UnitTestAutomationStrategy(dictionary, monitoringStationClient)
        )
    }
}

This approach clearly separates the concerns. Moreover, we have a nice overview of how we interpret everything in each test level. There’s one concert I hear ofter about Strategy pattern: we have just one implementation, why creating an interface? Clean boundaries between major components (components, not between every two classes in our code) are good mental barriers that help the programmers organizing their code. Many developers would notice a boundary here and intuitively keep the definition and execution separate. If the code is well-organized, any future refactoring of the execution part won’t affect the definitions. Remember: design patterns are not only for scenarios where you have two or more implementations. They are also for the future you.

Separated syntax, abstract syntax tree and execution part. Design of interpreters and compilers which works for Kotlin DSL.
Proven separation of concerns in nearly all interpreters and compilers.

In short…

Keep the DSL syntax, the definitions, and the execution layers separate. This is the basic (and very successful) design of nearly all interpreters, compilers, etc.

Summary

The Kotlin DSL showed in this article is an example of the flexible design. With just a couple of simple decisions, we built a solid foundation that can be later expanded in various directions. In the world of automated tests, the flexibility is important. Every new feature brings new challenges. The structure that worked yesterday, may not hold up tomorrow. Where to go from there? Once you start building your own DSL, you will quickly notice that each domain requires a slightly different approach. Service A may focus on data structures. Service B – on the proper sequence of events. Service C – on both. Keep that in mind!

Sample code

Find a complete example on Github: kotlindsl-testing-demo

Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments