Testing Files without Files

February 21, 2022

File operations become less and less common. As users, we store more and more data in a vendor storage due to its convenience. I remember the contacts file format but cannot locate one on a local machine — I have Google Contacts instead. As developers, we use datastores — from S3 to Hive Metastore. Such datastores are scalable, fault tolerant and cheap.

However, even if files and file systems are hidden behind various APIs — it doesn’t eliminate them. In this article I’ll show how to use Java fake file systems to test file interactions. It’s a fun approach but with its own pros and cons.

Overview

Let’s imagine that there is a Packages class we want to test.

interface Packages {

    fun pack(files: Iterable<File>): File
}

The pack method receives an enumeration of files and returns a file of the resulting package.

Options

Java IO

A classic approach using java.io.File.

The (fake) implementation:

interface Packages {

    fun pack(files: Iterable<File>): File

    class Impl(private val packagesRoot: File) : Packages {

        override fun pack(files: Iterable<File>): File {
            val packageFile = File(packagesRoot, "package.tar")

            return packageFile.apply { createNewFile() }
        }
    }
}

The corresponding test suite:

class PackagesTests {

    private lateinit var packagesRoot: File
    private lateinit var packages: Packages

    @BeforeEach
    private fun setUp() {
        packagesRoot = createTempDir()
        packages = Packages.Impl(packagesRoot)
    }

    @AfterEach
    private fun tearDown() {
        packagesRoot.deleteRecursively()
    }

    @Test
    fun pack() {
        val files = (0..10)
            .map { File(packagesRoot, "$it.txt") }
            .onEach { it.createNewFile() }

        assertThat(packages.pack(files)).exists()
    }
}

Pros:

Cons:

Java NIO

A modern approach with java.nio.file.Path. It might feel new but the NIO is available from Java 7 (2011).

💡 Hello there, a curious Android developer. java.nio.file.* is available from API 26 (8.0).

Both the interface and the implementation need changes:

interface Packages {

    fun pack(files: Iterable<Path>): Path

    class Impl(private val packagesRoot: Path) : Packages {

        override fun pack(files: Iterable<Path>): Path {
            val packageFile = packagesRoot.resolve("package.tar")

            return packageFile.apply { Files.createFile(this) }
        }
    }
}

The corresponding test suite:

JimFS is an in-memory java.nio.file.FileSystem implementation. Nope, it wasn’t created by Jim from The Office. It means Just In Memory File System. It doesn’t use disk at all.

class PackagesTests {

    private lateinit var packagesFileSystem: FileSystem
    private lateinit var packages: Packages

    @BeforeEach
    fun setUp() {
        packagesFileSystem = Jimfs.newFileSystem()

        packages = Packages.Impl(
            packagesRoot = packagesFileSystem.getPath("packages").apply {
                Files.createDirectory(this)
            },
        )
    }

    @AfterEach
    fun tearDown() {
        packagesFileSystem.close()
    }

    @Test
    fun pack() {
        val files = (0..10)
            .map { packagesFileSystem.getPath("$it.txt") }
            .onEach { Files.createFile(it) }

        assertThat(packages.pack(files)).exists()
    }
}

Pros:

Cons:

Okio

I call it a portable NIO since the Java NIO feels like an inspiration for the Okio FS API. Also it’s a separate artifact and supports Kotlin Multiplatform.

Both the interface and the implementation need changes:

interface Packages {

    fun pack(files: Iterable<Path>): Path

    class Impl(
        private val packagesFileSystem: FileSystem,
        private val packagesRoot: Path,
    ) : Packages {

        override fun pack(files: Iterable<Path>): Path {
            val packageFile = packagesRoot.resolve("package.tar")

            return packageFile.apply { packagesFileSystem.write(this) {} }
        }
    }
}

The corresponding test suite:

JimFS is not relevant here but there is a neat okio.fakefilesystem.FakeFileSystem doing the same thing.

class PackagesTests {

    private lateinit var packagesFileSystem: FileSystem
    private lateinit var packages: Packages

    @BeforeEach
    fun setUp() {
        packagesFileSystem = FakeFileSystem()

        packages = Packages.Impl(
            packagesFileSystem = packagesFileSystem,
            packagesRoot = "packages".toPath().apply {
                packagesFileSystem.createDirectory(this)
            },
        )
    }

    @Test
    fun pack() {
        val files = (0..10)
            .map { "$it.txt".toPath() }
            .onEach { packagesFileSystem.write(it) {} }

        assertThat(packagesFileSystem.exists(packages.pack(files))).isTrue()
    }
}

Pros:

Cons:

Options Performance

I’ve took execution measurements but please note that this is not a benchmark.

Time in the table is the average run duration.

N Java IO, ms Java NIO (JimFS), ms Okio (FakeFileSystem), ms
100 15 3 4
1000 100 9 20
10000 1040 30 95
100000 15880 160 860

No surprises here — RAM performs better than SSD.

However, tests creating thousands of files are uncommon. I can imagine having hundreds of tests (each creating dozens of files) though. Still — ergonomics might be a better choosing criteria here.

Decisions

The choice depends on circumstances. As for me — I think NIO is a great choice for JVM services, Okio — for Android applications. Also — more than zero tests is awesome, that’s all that matters.