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")
}
}
}
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")
}
}
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")
}
}
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")
}
}
}
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")
}
}
}
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")
}
}
}
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")
}
}
}
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)
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)
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)
}
In this way, we are composing DefaultMorning
from several implementations of interfaces via delegates.
This is in contrast to:
- Inheriting implementations via an abstract class
- Relying on default implementations
- 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 { /*...*/ }
but hardcode them like this:
class DefaultCoffee() :
Task by DefaultTask(), Coffee { /*...*/ }
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
Top comments (0)