Files, Boilerplate, and Testability

I’m working on Okio’s multiplatform filesystem API. It introduces 3 main types:

  • Path: a value object that identifies a file or directory
  • FileMetadata: a value object that describes a file or directory, including its type and size
  • Filesystem: a service object to read and write files and directories

Plus two helpers:

  • FakeFilesystem: an in-memory Filesystem for fast and deterministic tests! Plus it makes it easy to confirm that you didn’t forget to close anything.
  • ForwardingFilesystem: decorates another Filesystem 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 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)

And in our test we supply a fake:

  fun happyPath() {
    val filesystem = FakeFilesystem()
    val helloFileWriter = HelloFileWriter(filesystem)

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.