Easier Testing with Kotlin Delegates

brady_aiello

Brady Aiello

Posted on December 10, 2021

Easier Testing with Kotlin Delegates

TL;DR

You can use constructor injection of interfaces along with Kotlin delegates to make your code more modular without changing your class hierarchy. This has the benefit of not only making testing easier, but also code reuse, modularity, and easier dependency injection.

The Problem

Recently I had the opportunity to simplify a Kotlin class hierarchy for a client. These aren't the actual classes, but the structure was something like this:

interface Task {
    val scope: CoroutineScope
        get() = GlobalScope

    suspend fun doSomething(taskName: String): Unit {
        scope.launch {
            delay(1000L)
            print("I am $taskName")
        }
    }
}

interface Coffee: Task {
    fun makeCoffee() {
        print("Drip Drip")
        scope.launch {
            doSomething("making coffee")
        }
    }

    fun drinkCoffee() {
        print("Mmmmm...")
        scope.launch {
            doSomething("drinking coffee")
        }
    }
}

interface Breakfast: Task {
    fun eatBreakfast() {
        print("Om nom nom")
        scope.launch {
            doSomething("eating breakfast")
        }
    }
}

interface Morning:
    Task,
    Coffee,
    Breakfast

class DefaultMorning: Morning {
    fun morningRoutine() {
        makeCoffee()
        drinkCoffee()
        eatBreakfast()
    }

    override suspend fun doSomething(taskName: String) {
        scope.launch {
            delay(15000L)    
            println("I'm tired and I'm $taskName")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We have a couple layers of inheritance, using an interface of interfaces. If we want to test it without the delay, we'll need to override the doSomething() method. If we do that, we're not actually testing the DefaultMorning. Rather, we're testing something similar to DefaultMorning:

class TestMorning: Morning {
    fun morningRoutine() {
        makeCofee()
        drinkCoffee()
        eatBreakfast()
    }

    override suspend fun doSomething(taskName: String) {
        println("I'm tired and I'm $taskName")
    }
}
Enter fullscreen mode Exit fullscreen mode

We'll have to do the same thing for testing other classes as well:

class TestCoffee: Coffee {
    override suspend fun doSomething(taskName: String) {
        print("I am $taskName")
    }
}
Enter fullscreen mode Exit fullscreen mode

This begs the question, "Is there a better way?"

Enter Kotlin Delegates

Merriam-Webster defines delegate as a verb and a noun, and I think the former is more relevant:

1: to entrust to another
// "delegate authority"
// "delegated the task to her assistant"

That's what we want to do. We don't want DefaultRoutine to implement any interfaces itself. Instead, we want to delegate those implementations to someone else. We'll pass them as parameters that we can substitute during testing, without creating another class. Let's see what that would look like, starting at the bottom of our class hierarchy.

// Removing default interface impl for clarity
interface Task {
    val scope: CoroutineScope
    suspend fun doSomething(taskName: String): Unit
}

interface Coffee: Task {
    fun makeCoffee()
    fun drinkCoffee()
}

class DefaultCoffee(private val task: Task): 
    Task by task,
    Coffee {
    fun makeCoffee() {
        print("Drip Drip")
        scope.launch {
            doSomething("making coffee")
        }
    }

    fun drinkCoffee() {
        print("Mmmmm...")
        scope.launch {
            doSomething("drinking coffee")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Upside

What advantage does this have? Now we can more easily control DefaultCoffee's implementation, which comes in handy for testing. In production we'll use DefaultTask:

class DefaultTask: Task {
    override val scope: CoroutineScope
        get() = GlobalScope

    override suspend fun doSomething(taskName: String): Unit {
        scope.launch {
            // Some long network request
            delay(2000L)
            print("I am $taskName")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And we might have a Task implementation for testing:

class TestTask: Task {
    override val scope: CoroutineScope
        get() = TestScope()

    override suspend fun doSomething(taskName: String): Unit {
        scope.launch {
            print("I am $taskName")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We want to delegate the Task interface to some implementation, so DefaultCoffee doesn't need to implement it, or rely on default implementations. We'll do that using a Kotlin delegate. The delegate syntax uses the by keyword, to say, "I implement this Task interface by delegating it to some implementation."

To get there, we'll change Coffee a bit:

interface Coffee: Task {
    fun makeCoffee()
    fun drinkCoffee()
}

class DefaultCoffee(private val task: Task): 
    Task by task,
    Coffee {
    override fun makeCoffee() {
        print("Drip Drip")
        scope.launch {
            doSomething("making coffee")
        }
    }

    override fun drinkCoffee() {
        print("Mmmmm...")
        scope.launch {
            doSomething("drinking coffee")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now this lets us test the DefaultCoffee class directly, by swapping out Task implementations:

val defaultTask = DefaultTask()
val testTask = TestTask()

val defaultCoffee = DefaultCoffee(defaultTask)
val testCoffee = DefaultCoffee(testTask)
Enter fullscreen mode Exit fullscreen mode

No mocks or extending the actual class under test required!

Putting it all together

We can do the same thing with Breakfast:

interface Breakfast: Task {
    fun eatBreakfast()
}

class DefaultBreakfast(private val task: Task): Breakfast, Task by task {
    override fun eatBreakfast() {
        print("Om nom nom")
        scope.launch {
            doSomething("eating breakfast")
        }
    }
}

val defaultBreakfast = DefaultBreakfast(defaultTask)
val testBreakfast = DefaultBreakfast(testTask)
Enter fullscreen mode Exit fullscreen mode

Now we can do the same thing with Morning:

interface Morning :
    Task,
    Coffee,
    Breakfast

class DefaultMorning(
    private val task: Task,
    private val coffee: Coffee,
    private val breakfast: Breakfast
) : Morning,
    Task by task,
    Coffee by coffee,
    Breakfast by breakfast {
    override val scope: CoroutineScope
        get() = task.scope

    override suspend fun doSomething(taskName: String) =
        task.doSomething(taskName)
}
Enter fullscreen mode Exit fullscreen mode

In this way, we are composing DefaultMorning from several implementations of interfaces via delegates.

This is in contrast to:

  1. Inheriting implementations via an abstract class
  2. Relying on default implementations
  3. Making DefaultMorning implement everything itself

Unlike 1, we don't need a deep class hierarchy. Unlike 2, we can test our class directly, and don't need to make the default implementation open. Unlike 3, DefaultMorning is terse.

Through this composition, we can still have an "is a" relationship that might be required by whatever API we're working with, but they can be implemented in a "has a" sort of way.

❓ Isn't this redundant?

You may reasonably ask, "Why do we need to override scope and doSomething() if they're already implemented by our delegate(s)?

If you omit them, IntelliJ will tell you something like:


Class 'DefaultMorning' must override public open suspend fun doSomething(taskName: String): Unit defined in com.mynamespace.DefaultMorning because it inherits many implementations of it

This is because the Task, Coffee, and Breakfast delegates all implement the Task interface, and the compiler doesn't know which one to use. (Even though we told it specifically which implementation we want (by defaultTask) to use). So we have to delegate those calls (more) explicitly.

⚠️ Don't hardcode delegates

If you don't pass delegates into the constructor, like this:

class DefaultCoffee(private val task: Task) :
    Task by task, Coffee { /*...*/ }
Enter fullscreen mode Exit fullscreen mode

but hardcode them like this:

class DefaultCoffee() : 
    Task by DefaultTask(), Coffee { /*...*/ }
Enter fullscreen mode Exit fullscreen mode

then you are tightly coupling that Task implementation to DefaultCoffee. Swapping out implementations will be difficult, defeating our major purpose of using delegates. You might need to do some mock gymnastics and/or override methods of the class under test with an object declaration 😫. Instead, treat yourself to a pleasurable testing experience, and practice constructor injection of your delegates 😄.

Conclusion

That's all I got for today! Delegates seemed strange when I first learned about them, but after struggling with testing things that heavily relied on inheritance and default interface implementations, I found delegates helped me test with ease. I hope it helps you as well.

If you have questions or comments, let me know in the comments below. You can also reach me on Twitter at @AielloBrady, or on the Kotlin Slack. And if you find this interesting, and might like to work with or work at Touchlab, we'd love to hear from you!

class Brady(val fingers: Typing): Typing by fingers
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
brady_aiello
Brady Aiello

Posted on December 10, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related