I’m working on Okio’s multiplatform filesystem API. It introduces 3 main types:
Path
: a value object that identifies a file or directoryFileMetadata
: a value object that describes a file or directory, including its type and sizeFilesystem
: a service object to read and write files and directories
Plus two helpers:
FakeFilesystem
: an in-memoryFilesystem
for fast and deterministic tests! Plus it makes it easy to confirm that you didn’t forget to close anything.ForwardingFilesystem
: decorates anotherFilesystem
for observability, fault injection, or transformations.
I’m happy with how it’s shaping up.
Implicit and Explicit Dependencies
But I’m also anxious about how it compares to Kotlin’s JVM-only kotlin.io
APIs. Let’s use it to write a million lines to a file:
fun writeHellos(name: String) {
File("hello1M.txt").bufferedWriter().use {
for (i in 0 until 1_000_000) {
it.write("$i hello, $name!\n")
}
}
}
Doing the same thing in Okio is similar but it also needs the Filesystem.SYSTEM
object. That clutters up the calling code:
fun writeHellos(name: String) {
Filesystem.SYSTEM.sink("hello1M.txt".toPath()).buffer().use {
for (i in 0 until 1_000_000) {
it.writeUtf8("$i hello, $name!\n")
}
}
}
It would be so easy to get rid of this! We could just add a short extension function:
fun Path.sink() = Filesystem.SYSTEM.sink(this)
But doing so harms testability because it hides the Filesystem.SYSTEM
dependency.
‘Injecting’ the Filesystem
For testability, let’s avoid using Filesystem.SYSTEM
directly. To get it into the writeHellos
function we have to make it a constructor parameter of its enclosing class.
class HelloFileWriter(
private val filesystem: Filesystem
) {
fun writeHellos(name: String) {
filesystem.sink("hello1M.txt".toPath()).buffer().use {
for (i in 0 until 1_000_000) {
it.writeUtf8("$i hello, $name!\n")
}
}
}
}
From our main function we supply the real filesystem:
fun main(vararg args: String) {
val helloFileWriter = HelloFileWriter(Filesystem.SYSTEM)
helloFileWriter.writeHellos(args.single())
}
And in our test we supply a fake:
@Test
fun happyPath() {
val filesystem = FakeFilesystem()
val helloFileWriter = HelloFileWriter(filesystem)
helloFileWriter.writeHellos("Jesse")
assertThat(filesystem.metadata("hello1M.txt".toPath()).size)
.isEqualTo(20_888_890L)
}
Testability Traps
I expect that almost all Kotlin and Java I/O code is only tested against a real filesystem, if at all. It’s easy to start out making direct calls out to the filesystem, and awkward to undo that afterwards.
But testing against the real filesystem is incredibly shitty!
- It’s slower.
- It makes tests flaky. Are tests isolated from each other? What about for concurrent execution?
- It risks damaging your development environment. Do your tests slowly fill up your hard drive? Or quickly erase it?
- It can’t do fault injection. Can you test what happens when a write fails?
- It can’t detect leaks. Can you confirm that every file opened was also closed?
Okio + Files
Okio’s Filesystem is coming soon. It’s small, multiplatform, and testable.