Blog

Kotlin Multiplatform’s three levels of testing with Kotest

01 Nov, 2022
Xebia Background Header Wave

Kotlin Multiplatform allows us to share common Kotlin code between various platforms, including JVM, JavaScript, iOS, Android, and native. This is useful when we have shared business logic relevant to all platforms. If functions in that common codebase require a platform-specific implementation, then Kotlin Multiplatform offers a mechanism of expected and actual declarations to essentially create placeholders that are implemented per platform. We examine this mechanism later in this article. A Kotlin Multiplatform project contains multiple modules called source sets to enable such code sharing. By default, each platform has two source sets, one for the code (jvmMain, jsMain) and one for the tests (jvmTest, jsTest) and additionally two source sets for common code (commonMain and commonTest) that are used in all main compilations of a project. These modules are structured in a hierarchy to provide any level of code sharing.

Kotlin Multiplatform Hierarchy

Source set hierarchy in Kotlin Multiplatform

One use case is to share dependencies. Below we see an example where commonTest defines Kotest dependencies used for all platforms, and the JVM-specific dependencies are added to the jvmTest source set.

sourceSets {
    val <code>commonTest by getting {
        dependencies {
            implementation(libs.kotest.assertions.core)
            implementation(libs.kotest.framework.engine)
            implementation(kotlin("test-common"))
            implementation(kotlin("test-annotations-common"))
        }
    }

    val jvmTest by getting {
        dependencies {
            implementation(libs.kotest.runner.junit5)
        }
    }
}

Each platform also has its own specific code so that, for instance, the JVM part of your application can implement a server, the front-end is built in JavaScript code, and the mobile apps are created based on the source sets for Android and iOS.

This article shows how we can test all three layers of code in a Kotlin Multiplatform project: common, expected / actual declaration, and platform-specific code. We use Kotest as our testing framework because it has built-in multiplatform support. The framework delegates testing to platform-specific engines, such as JUnit and Mocha. All code samples can be found on GitHub.

Common code

Code that is shared by all platforms by default resides in the commonMain source set. It wouldn’t make sense to write tests for it in every platform, so instead, we can use the commonTest source set to store the common test suite. Let’s say that we need a function that calculates the factorial of a number in source set commonMain:

package nl.bjornvanderlaan.factorial

fun factorial(number: Int): Int
    = (1..number).fold(1) { acc, i -> acc * i}

We can provide the test code in commonTest:

package nl.bjornvanderlaan.factorial

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.ints.shouldBeExactly

class FactorialTest : StringSpec({
    "Factorial of 5 is 120" {
        factorial(5) shouldBeExactly 120
    }
})

As we can see, testing common code works the same as in ‘normal’ Kotlin projects. The cool part however is that this function can now be used in all possible platforms, which can be a huge bonus if we write all the business logic here.

Expected / Actual declarations

Sometimes, there are functions that we want to use in our common code, but they can be implemented much more efficiently with platform-specific code. For instance, Kotlin’s multiplatform standard library does not provide a way to encode a string in base64. We could roll our own implementation or look for an open-source library, but some of our target platforms already provide this functionality out of the box. Kotlin provides a mechanism of expected and actual declarations to tackle this situation. With this mechanism, a common source set defines an expected declaration, and platform-specific source sets provide the actual declaration corresponding to the expected declaration and the actual implementation. Besides functions, this mechanism also works for other constructs such as classes, enums, and annotations. Below we see an example of this mechanism for the JVM. The code is based on a tutorial from JetBrains.

/**
 * Source set: commonMain
 */
package nl.bjornvanderlaan.base64

expect fun encodeBase64(input: String): String

fun encodeGreeting(name: String): String {
    return "Greetings $name, from Kotlin Multiplatform!"
}

/**
 * Source set: jvmMain
 */

package nl.bjornvanderlaan.base64

import java.util.Base64

actual fun encodeBase64(input: String): String {
    return Base64.getEncoder().encode(input.encodeToByteArray()).decodeToString()
}

As we can see, the expected function in commonMain can be used by other functions such as encodeGreeting. Through this mechanism, we can write common code where it makes sense and let leave placeholders where we expect the platforms to deliver an actual implementation. Similar to the common code, we can write our test in commonTest:

/**
 * Source set: commonTest
 */
class Base64Test: StringSpec({
    "String is properly encoded to Base64" {
        val input = "Kotlin is awesome"

        val encodedInput = encodeBase64(input)

        encodedInput shouldBe "S290bGluIGlzIGF3ZXNvbWU="
    }
})

When we let Gradle test our project, it automatically executes this test for all platform-specific variants of the expected function. So even though we have multiple actual implementations, we do not need to repeat our test suite.

Specific Function

Finally, there are parts of the application that we want to build for specific platforms only. Because source sets are organised hierarchically, we can choose which test source we want to put our tests in. When we make a web application, we want the server code to be written specifically for the JVM, and the JavaScript source set would contain the front-end code. The tests are then likely put in jvmTest and jsTest source sets, respectively. We can use Kotest to write tests like we would for single-platform Kotlin projects. The advantage of Kotest is that we have one unified testing framework, and we can also use all helper functions and custom assertions from parent source sets. Let’s say that we want to write a function for the JVM platform that calculates the factorial of a factorial of a number. Note that the value is already bigger than what Integer can handle if the number is 4. Luckily, as this function is specific to the JVM, we can use BigInteger for this purpose.

/**
 * Source set: jvmMain
 */
package nl.bjornvanderlaan.factorialoffactorial

import nl.bjornvanderlaan.factorial.factorial
import java.math.BigInteger

fun factorialOfFactorial(number: Int): BigInteger {
    val bigNumber = factorial(number).toBigInteger()

    var currentNumber = BigInteger.ONE
    var accumulator = BigInteger.ONE

    while(currentNumber <= bigNumber) {
        accumulator *= currentNumber
        currentNumber++
    }
    return accumulator
}

The function above also shows how the jvmMain source set can use the factorial function from its parent commonMain in the same way it can use its own functions. The function above can be tested with Kotest in the same way as we have seen in the other examples:

/**
 * Source set: jvmTest
 */
package nl.bjornvanderlaan.factorialoffactorial

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.math.BigInteger

class FactorialOfFactorialTest: StringSpec({
    "Factorial of factorial of 4 is 620448401733239439360000" {
        factorialOfFactorial(4) shouldBe BigInteger("620448401733239439360000")
    }
})

This function and its tests are defined in the JVM-specific part of our application and can, therefore, benefits from classes like BigInteger.

Intermediate levels

The examples above assume only two levels of hierarchy: the common parent and its platform-specific children. In practice, we might introduce more levels. For instance, if we also build a mobile application for iOS and Android, we might have an intermediate source sets mobileMain that depends on the commonMain source set and acts as the parent for iosTest and androidMain. In that case, the mobileTest source set can contain tests for functions implemented in mobileMain or expected functions implemented in child source sets.

Conclusion

Kotlin Multiplatform helps us reduce the time spent on writing and maintaining the same code for different platforms, and Kotest helps us to unify our testing efforts by providing a multiplatform framework. Multiplatform projects are structured as a hierarchy of source sets. The rules for testing are the same at every node: Kotest executes our tests on either the implementation in the main source set itself or fetches the actual implementations from child source sets. As such, Kotlin Multiplatform offers us the flexibility to create a super DRY codebase and test suite, regardless of the number of platforms we want to support.

Bjorn van der Laan
Software engineer at Xebia Software Development
Questions?

Get in touch with us to learn more about the subject and related solutions