Parandrus

Node.js testing: Using a virtual filesystem as a mock

April 14, 2020

Testing modules that interact with the filesystem can be tricky. Typically you mock indivdual methods of the fs module, but his can be a bit tedious if you have to mock a lot of different calls. The mock-fs module streamlines this by letting you provide a simple mapping of paths to file contents and it mostly works. However if your code uses dynamic requires, you need to ensure the required files are all present in your mock filesystem.

This post shows an alternative method using unionfs and memfs. The advantage of this method is that it allows you to overlay your mock over the actual filesystem, ensuring that dynamic requires continue to work as expected.

The example module we want to test exports a catFiles function that reads all the files in a directory and concatenates their content:

import * as readdirp from "readdirp"
import * as fs from "fs"

export async function catFiles(dir: string) {
  const files = await readdirp.promise(dir)
  const fileContents = await Promise.all(
    files.map((file) =>
      fs.promises.readFile(file.fullPath, { encoding: "utf-8" })
    )
  )
  return fileContents.join("\n")
}

To mock the filesystem we replace the fs module’s implementation with unionfs. unionfs combines different fs modules into a single filesystem, looking up files in the order of its composing fs modules. union.ts#promiseMethod shows how this works under the hood: it tries to call the fs method on each of its filesystems in order until one succeeds.

Initially we setup unionfs with just the standard fs module:

jest.mock(`fs`, () => {
  const fs = jest.requireActual(`fs`)
  const unionfs = require(`unionfs`).default
  return unionfs.use(fs)
})

In our test setup we then create an in-memory filesystem using memfs with the filesystem contents to use as our mock and add it to our union filesystem:

import { Volume } from "memfs"
...
const vol = Volume.fromJSON(
  {
    "global.css": "html { background-color: green; }",
    "style.css": "body: {color: red;}",
  },
  "/tmp/www"
)
fs.use(vol)

Complete example

cat-file.test.ts:

jest.mock(`fs`, () => {
  const fs = jest.requireActual(`fs`)
  const unionfs = require(`unionfs`).default
  unionfs.reset = () => {
    // fss is unionfs' list of overlays
    unionfs.fss = [fs]
  }
  return unionfs.use(fs)
})
import * as fs from "fs"
import { Volume } from "memfs"
import { catFiles } from "./cat-files"

afterEach(() => {
  // Reset the mocked fs
  ;(fs as any).reset()
})

test("it reads the files in the folder", async () => {
  // Setup
  const vol = Volume.fromJSON(
    {
      "global.css": "html { background-color: green; }",
      "style.css": "body: {color: red;}",
    },
    "/tmp/www"
  )
  const fsMock: any = fs
  fsMock.use(vol)

  // Act
  const combinedText = await catFiles("/tmp/www")

  // Verify
  expect(combinedText).toEqual(
    "html { background-color: green; }\nbody: {color: red;}"
  )
})

© 2023