Kotlin-Multiplatform Shared Test Resources

Verifying your code regardless of platform

Reddit
LinkedIn

A usual test strategy is to have test fixture files, be it JSON, binary, or plain text files, to consume in tests. You may use these files for test input, test verification, or both. Using this strategy with Kotlin Multiplatform (KMP), you can ensure that each platform is behaving as intended.

Before

We had the same code and same unit tests duplicated across platforms and chose to use KMP to share this logic and their tests across platforms. These tests used test fixtures as input, so our tests needed to read those resource files. The problem being, Kotlin/Native doesn’t have support for I/O.

So how can you use those test fixtures across platforms? Keep reading to find out.

Note: This blog post assumes that you have a basic understanding of Kotlin Multiplatform.

Test Setup

The example in this post will show an example using binary protobuf files shared across JVM and iOS tests.

To get started, you need to build your common test. It won’t compile yet, but it will give you a frame of reference for what you’re building towards.

my-project/src/commonTest/kotlin/com/squareup/calculator/kmp/MyTest.kt

@Test fun test_using_test_fixtures() {
  val input = readBinaryResource("./test_case_input_one.bin")
  // Parse binary input and use in the test
}

The above is a plain ol’ Kotlin test that calls readBinaryResource() with a filename. You can find the definition of readBinaryResource() in the next section.

To make sure your common test will compile and run, it needs to be included in the source sets.

my-project/build.gradle.kts

kotlin {
  sourceSets {
    val commonTest by getting {
      dependencies {
        implementation(kotlin("test"))
      }
    }
  }
}

Test Fixtures

In the above test, the resource filename passed in is "./test_case_input_one.bin". It follows that this file needs to exist before the test will pass.

Only one thing is needed for this step: take your file (in this case, a binary file representing a model I’d like to deserialize and test against) and add it to my-project/src/commonTest/resources/.

Read resources

With that in place, the next step is to read the files into a ByteArray. Because this will look different for the JVM vs. iOS, you need to use the expect/actual pattern typical for KMP.

To start, the expected function is defined in commonTest:

my-project/src/commonTest/kotlin/ResourceLoader.kt

/** Read the given resource as binary data. */
expect fun readBinaryResource(
    resourceName: String
): ByteArray

This function expects a filename, and it will return a ByteArray of the contents as a result. Because this is test code, we’re not worried about making this read concurrent.

Now, to implement it per platform. This is short and sweet for JVM:

my-project/src/jvmTest/kotlin/readBinaryResource.kt

/** Read the given resource as binary data. */
actual fun readBinaryResource(
  resourceName: String
): ByteArray {
  return ClassLoader
    .getSystemResourceAsStream(resourceName)!!
    .readBytes()
}

The iOS code is a little more complex, partly because we need to split the resource name to get the file extension, but it’s still doable:

my-project/src/iosTest/kotlin/readBinaryResource.kt

/** Read the given resource as binary data. */
actual fun readBinaryResource(
  resourceName: String
): ByteArray {
  // split based on "." and "/". We want to strip the leading ./ and 
  // split the extension
  val pathParts = resourceName.split("[.|/]".toRegex())
  // pathParts looks like 
  // [, , test_case_input_one, bin]
  val path = NSBundle.mainBundle
    .pathForResource("resources/${pathParts[2]}", pathParts[3])
  val data = NSData.dataWithContentsOfFile(path!!)
  return data!!.toByteArray()
}

internal fun NSData.toByteArray(): ByteArray { 
  return ByteArray(length.toInt()).apply {
    usePinned {
      memcpy(it.addressOf(0), bytes, length)
    }
  }
}

With all of that complete, you might think we’re done here. And you’d be partially correct: JVM is up and running at this point. But if you try to run the iOS tests now, they will fail to find the file.

Share test resources

The final problem to tackle is that the iOS code looks someplace different for these test resource files. While sharing test resources isn’t natively supported by KMP at the time of writing, there are a couple of different solutions. You can pick which solution works best for you.

One option is to copy the files to where the tests are looking for them. A gradle task can do this!

my-project/build.gradle.kts

tasks.register<Copy>("copyiOSTestResources") {
  from("src/commonTest/resources")
  into("build/bin/iosX64/debugTest/resources")
}

tasks.findByName("iosX64Test")!!.dependsOn("copyiOSTestResources")

Two things happen here:

  1. A task is registered that takes all the resources from commonTest and copies them into iosX64/debugTest. This path is the default place NSBundle.mainBundle will look for files. You don’t need to use “resources” at the end of the path, but if you change it, make sure you update readBinaryResource() to match.
  2. Make iosX64Test depend on the new task so the files are always copied over before the tests run. You could make this more sophisticated by only copying files if they haven’t been copied yet.

That’s the end! Those resources are officially shared across platforms.

After