I like writing unit tests that read test cases from a data file:
- Zipline does ECDSA and EdDSA signing. It gets test data from Project Wycheproof.
- OkHttp does URL parsing. It gets test data from The web-platform-tests Project.
- OkHttp-ICU does Unicode Normalization. It gets its test data from Unicode’s test suite.
In each case I get to leverage somebody else’s hard work to define expected inputs and outputs. Using an upstream project’s test file verbatim makes it easy to apply updates.
CWD in Kotlin/Multiplatform Tests
Doing this on Kotlin/JVM is very easy. Gradle’s test runner conveniently sets the test process’s current working directory (CWD) to the enclosing module’s directory:
val file = File("src/test/testdata/file.txt")
val testData = FileInputStream(file).use { inputStream ->
readTestData(inputStream)
}
Kotlin/Native is trickier ’cause java.io.File
doesn’t exist there. But I prefer Okio’s FileSystem anyway:
val path = "src/commonTest/testdata/file.txt".toPath()
return FileSystem.SYSTEM.read(path) {
readTestData(this)
}
But soon our luck runs out.
On Kotlin/JS the test’s working directory is not the enclosing module’s directory; instead it’s far off in child of a build/
directory off the project root (KT-49125). We’ll also need the optional okio-nodefilesystem
artifact to read files from JavaScript.
And when tests run on the iOS simulator, the working directory is completely unrelated to the project directory.
Set an Environment Variable Instead!
We can’t rely on the current working directory to be consistent so we’ll tell our test process where to look using an environment variable.
Paste this in your build.gradle.kts
to set an environment variable for each kind of test:
allprojects {
tasks.withType<KotlinJvmTest>().configureEach {
environment("ZIPLINE_ROOT", rootDir)
}
tasks.withType<KotlinNativeTest>().configureEach {
environment("SIMCTL_CHILD_ZIPLINE_ROOT", rootDir)
environment("ZIPLINE_ROOT", rootDir)
}
tasks.withType<KotlinJsTest>().configureEach {
environment("ZIPLINE_ROOT", rootDir.toString())
}
}
Note the SIMCTL_CHILD_
prefix! It’s how I got the environment variable to propagate into the iOS simulator. This StackOverflow question tipped me off on it. I couldn’t find formal documentation for this thing.
Reading that Environment Variable
Just as we set our environment variable in a different way on each platform, we gotta do similarly to read it back.
In commonTest
:
internal expect fun getEnv(name: String): String?
In jvmTest
:
internal actual fun getEnv(name: String): String? =
System.getenv(name)
In jsTest
:
internal actual fun getEnv(name: String): String? =
js("globalThis.process.env[name]") as String?
And in each native target:
import platform.posix.getenv
import kotlinx.cinterop.toKString
internal actual fun getEnv(name: String): String? =
getenv(name)?.toKString()
Finally the payoff goes in commonTest
:
private val ziplineRoot = getEnv("ZIPLINE_ROOT")!!.toPath()
Multiplatform Tests Are Rad
It takes some setup to read files and do environment variables in Kotlin/Multiplatform.
But once it’s done? I get to verify that my code does the same thing on every platform. I love it.