1. Installation
  2. Getting Started
  3. Example
  4. Sandbox
  5. Dir
  6. File
  7. Snapshots
  8. Additional Notes

fs-testkit

fs-testkit provides testing utilities to write better tests that use the file system

Features:

Installation

The package is published to npm as fs-testkit, it can be installed with:

# pnpm
pnpm add --save-dev fs-testkit

# yarn
yarn add --dev fs-testkit

# npm
npm install --save-dev fs-testkit

Getting Started

Using fs-testkit starts with a Sandbox instance. A sandbox is tied to an underlying root directory on the filesystem. All of the functionality and features of this library are available from this sandbox instance.

import { createSandbox } from "fs-testkit";

const sandbox = await createSandbox();

Continue with the following Example that walks through some of the available features or read more about what can be done with this Sandbox instance.

Example

Creating a sandbox can be done with the async function createSandbox. The following example enables the prettier option so that supported files are automatically prettier’d, this can help when reviewing output from strings that might have been formatted oddly in an editor. This example also does not specify a root directory so one will be automatically created in the operating system’s default temp directory.

import { createSandbox } from "fs-testkit";

const sandbox = await createSandbox({ prettier: true });

Scaffolding an entire nested file tree can be done quickly using Sandbox.scaffold:

await sandbox.scaffold({
  // this is invalid json but it will be prettier'd into valid json
  // because the prettier option is enabled
  "package.json": `
    {
      name: "hello-world",
      dependencies: {},
      devDependencies: {}
    }
  `,

  "README.md": `
    # The hello-world package
    This represents a test package
  `,

  // this structure will create a `src/index.js` file
  src: {
    "index.js": `
      import { writeFile } from "fs/promises";

      export default async function helloWorld() {
        console.log("hello world!");
        await writeFile("hello-world.txt", "hello world!");
      }
    `,
  },
});

When looking at the root directory, or any directory, it can be useful to see the layout quickly and clearly, or even use the string to assert against in a test:

console.log(await sandbox.root.treeString());

The Dir.treeString output from above would log:

.
├── src
│   └── index.js
├── README.md
└── package.json

Often tests involving the filesystem compare before and after, or even multiple states. fs-testkit provides snapshots for this:

await sandbox.snapshot.create("before-change");

// some action that creates a difference on the filesystem,
// in tests this could be a codemod, a file generator, or some script
await sandbox.file("CHANGELOG.md").write(`
# v1.0.0
* Initial Release!
`);

await sandbox.snapshot.create("after-change");
const diffs = await sandbox.root.diff("before-change", "after-change");

console.log(diffs);

The Dir.diff output logged would be:

[{ path: "CHANGELOG.md", type: "add" }];

Sandbox

Creating a Sandbox

The preferred method of creating a sandbox is using the createSandbox function exported from the package:

import { createSandbox } from "fs-testkit";
await createSandbox({
  /* options */
});

While the async createSandbox is preferred, a Sandbox instance can be also created synchronously, but the async setup still needs to be called before using any async methods, see: Sync Instantiation / Async Setup.

The options for creating a sandbox include:

{
    prettier?: boolean,
    autoCleanUp?: boolean,
    root?: {
      path?: string,
      allowExisting?: boolean,
      allowDestroyRoot?: boolean
    }
}

The root Directory

The sandbox root directory is represented by a Dir instance at Sandbox.root. This is often the starting point for pathing and filesystem operations.

await sandbox.root.at("src/index.js").create(`
  export default function helloWorld() {
    console.log("Hello World!");
  }
`);

Pathing

While the Sandbox.root has all available Dir methods, there are a few methods that are available on the Sandbox instance directly as shortcuts:

// using the root directory's `at` method for relative paths (same as `Sandbox.root.at`)
const relative = sandbox.at("relative/path/to/src");

// reference a child directory of the root directory (same as `Sandbox.root.dir`)
const dir = sandbox.dir("src");

// reference a file of the root directory  (same as `Sandbox.root.file`)
const file = sandbox.file("README.md");

For more pathing options available on sandbox.root, see the Dir pathing documentation.

Scaffolding

The Sandbox.scaffold method is a shortcut for the Sandbox.root.scaffold. The scaffold method makes it quick to set up the sandbox’s filesystem without the hassle of using multiple filesystem APIs. See the scaffolding documentation for Dir for more information.

Sync Instantiation / Async Setup

A Sandbox instance can be created synchronously, and then set up asynchronously. Calling the setup method is required before any filesystem operations can be run.

import { Sandbox } from "fs-testkit/sandbox";

// accepts same options as `createSandbox`
const sandbox = new Sandbox({
  /* options */
});
await sandbox.setup();

Cleaning Up a Sandbox

Manual cleanup of a sandbox root directory can be done by calling destroy:

await sandbox.destroy();

Automatic cleanup is the default when the Sandbox is created without a specified root path, in which a temp directory is automatically created. However, the autoCleanUp option can be specified to allow cleaning up a directory when a root path is specified:

const sandbox = await createSandbox({ autoCleanUp: true });

In the case the root path already exists you have to also provide permission to acknowledge that it will be cleaned up by passing in the option root.allowDestroyRoot:

const sandbox = await createSandbox({
  autoCleanUp: true,
  root: {
    path: "/some/path",
    allowDestroyRoot: true,
  },
});

Under the hood automatic cleanup works by performing two passes:

  1. By using a FinalizationRegistry, when the Sandbox instance is garbage collected on supporting runtimes the root directory is also deleted from the filesystem. This aims to keep the sandbox root directory lifespan in sync with the corresponding Sandbox instance.
  2. If the Sandbox instance is held on to for some reason and is never garbage collected then the clean up happens when the beforeExit event is fired.

Dir

The Dir instance represents a reference to a location within the sandbox root. Creating an instance does not mean that it exists on the filesystem, it’s only a reference, although Dir does have methods for managing filesystem operations.

See the API documentation for the full capabilities of Dir.

Pathing

A Dir instance has some immutable properties that reflect its position in the filesystem:

// The directory's name
const dirName = dir.name;

// The directory's path relative to the sandbox root
const relativePath = dir.path;

// The directory's absolute path
const absolutePath = dir.absolutePath;

The directory’s parent can be referenced by its parent property which is also a Dir, this property is null if the directory is the sandbox root:

const parent = dir.parent;

One of the advantages of a Dir is that it makes it easy to traverse a file tree in a chaining fashion where the pathing can return a Dir or File:

// pathing chains through multiple instances of `Dir`
const file = dir.dir("packages").dir("core").file("package.json");

// similar to the example above but uses `at`
const relativePathing = dir.at("packages/core/package.json");

Note: the Dir.at method assumes that something is a Dir or File based on the ending having an extension or not, this can be controlled by specifying if it should be a "File" or "Dir":

const directoryWithPeriod = dir.at("packages/core/testing.files", "Dir");
const fileWithNoExtension = dir.at("packages/core/Makefile", "File");

At any point saving a reference to a Dir instance preserves its immutable reference within the filesystem structure. The Dir and File instances can be flexibly chained:

const packages = dir.dir("packages");
const packageA = packages.dir("package-A");
const packageB = packages.dir("package-B");

const distDirFromA = packageA.at("dist");
const indexRouteFromB = packageB.at("src/routes/index.js");

Traversing up can be done with Dir.parent or ".." used within Dir.at:

const packagesDir = dir.dir("packages");
const rootPackageJson = packagesDir.parent.file("package.json");
const altRootPackageJson = packagesDir.at("../package.json");

Scaffolding

One tedious task when dealing with the filesystem is reading and creating the structure of the filesystem. This is clearer when representing directories as objects with the keys represent the names of its files or directories, with files having values of either strings or buffers, for example:

{
  ['file.js']: `console.log("hello world");`,
  subdir: {
    ['data']: Buffer.from("data data data")
  }
}

This object structure can be returned from Dir.tree or passed to Dir.scaffold to create its contents on the filesystem.

The Dir.scaffold method can create nested directories and files:

await dir.scaffold({
  // create a README.md file in the sandbox root directory
  ["README.md"]: `# README`,

  // directories are objects whose keys are the names of other dirs or files,
  // this directory would be a directory named `src`
  src: {
    ["sub directory"]: {},
    ["hello.js"]: `console.log('hello world');`,
  },
});

It also supports creating files from buffers:

await dir.scaffold({
  // files can be represented by buffer data
  ["file buffer"]: Buffer.from("data data data"),
});

The scaffold method can specify the same arguments available to File.create by specifying the arguments as an array tuple:

await dir.scaffold({
  ["CHANGELOG.md"]: [
    // first argument represents the file contents, string or buffer
    `v1.0.0 Big Release!`,
    // second argument represents the options passed into `File.create`
    { prettier: true, overwrite: true },
  ],
});

The return of Dir.scaffold includes a nested typed object of File instances that match the structure of the input. For example…

const result = await dir.scaffold({
  ["README.md"]: `# README`,

  src: {
    ["sub directory"]: {},
    ["hello.js"]: `console.log('hello world');`,
  },
});

… would create a result represented by a matching nested object of File instances:

{
  ["README.md"]: File,

  src: {
    ["sub directory"]: {},
    ["hello.js"]: File,
  },
}

The Dir instances are not included unless the options.includeDirInstances is set to true in which case the result would include a __dir representing the current object’s Dir instance:

{
  __dir: Dir
  ["README.md"]: File,

  src: {
    __dir: Dir
    ["sub directory"]: { __dir: Dir },
    ["hello.js"]: File,
  },
}

Filesystem Structure

An object representing the directory structure can also be returned from Dir.tree:

console.log(await dir.tree());

Would log:

{
  src: { 'hello.js': 'hello.js' },
  'README.md': 'README.md',
  data: 'data',
  'package.json': 'package.json'
}

But also different masks can be passed in for the files that are text-based or blobs:

console.log(
  await sandbox.root.tree({
    textFileMask: "file-contents",
    blobFileMask: "hash",
  }),
);

Which would now log:

{
  src: { 'hello.js': "console.log('hello!');" },
  'README.md': '# Hello!',
  'package.json': '{ name: "hello", dependencies: {} }',
  data: 'e1e849238bcf2b794f63e13c0ed1604aac455455'
}

Instead of an object, the filesystem can also be visualized as a tree string with Dir.treeString. console.log(await dir.treeString()) would log:

.
├── src
│   └── hello.js
├── README.md
├── package.json
└── data

Dir Properties

Dir instances have the following properties:

See the Dir API documentation for more.

Dir Filesystem Operations

Dir instances support the following filesystem operations:

See the Dir API documentation for the full set of methods available.

Diffing

Dir can be diffed using snapshots. See: Diffing Dir with Snapshots

File

The File instance represents a reference to a location of a file within the sandbox directory. The existence of the instance does not mean the file actually exists, but that can be determined by using its filesystem operations.

See the API documentation for the full capabilities of File.

Creating (or updating) a file on the filesystem

Creating a file is one of the most common File filesystem operations. This can be done with the write method (or its alias create). If the file is being updated/replaced then the { overwrite: true } option must be passed. The global prettier option can be overridden on a case-by-case basis by passing in the { prettier } option.

await dir.file("hello-world.md").write(
  `
  # Hello World
  ## Hello
  ## World
  `,
  { prettier: true, overwrite: true },
);

With supporting file types the prettier option can be useful to preserve indentation in the editor but have the final output look pretty on the filesystem.

See the other filesystem operations available on File.

Diffing

A File instance can be diffed across different snapshots. This can be handy to get individual differences within a text file or check if a blob is the same, new, removed or modified, see: Diffing File with Snapshots.

File Properties

The following properties exist on instances of File:

See the API documentation for more details.

File Filesystem Operations

See the API documentation for more details.

Snapshots

Snapshots capture the current state of the filesystem when they are created. They make it easier to perform a diff between two states or even to return the filesystem to a previous state. Snapshot methods are accessed via Sandbox.snapshot, see the full API documentation for more details.

Creating Snapshots

Creating a snapshot captures the current state of the filesystem. If a string is specified it will be used to name the snapshot, otherwise a random uuid string will be used and returned.

await sandbox.snapshot.create("any unique string");
const snapshotName = await sandbox.snapshot.create();
const givenSnapshotName = await sandbox.snapshot.create("given name");

If a snapshot name is already in use an error will be thrown.

Deleting Snapshots

Snapshots can be deleted by name:

await sandbox.snapshot.delete("name of snapshot");

Restoring Snapshots

Snapshots can be restored by name. This will restore the filesystem to the state it had at the time of the snapshot. This is a destructive operation and will modify files to how they existed at the snapshot, including removing files and directories that did not exist.

await sandbox.snapshot.restore("name of snapshot");

Diffing Dir with Snapshots

Dir.diff can be used to get the differences of a directory between two snapshots. This example uses Sandbox.root but any Dir can be used and the diff will be scoped to that directory.

const diffs = await sandbox.root.diff("snapshot-a", "snapshot-b");
// or
const diffs2 = await sandbox.root.diff("snapshot-a", "snapshot-b", {
  includeDirs: false,
});

console.log(diffs);

The logged output would be an array of diff objects like:

[
  {
    "path": "src",
    "type": "modify"
  },
  {
    "path": "src/CHANGELOG.md",
    "type": "add"
  },
  {
    "path": "src/routes",
    "type": "equal"
  },
  {
    "path": "README.md",
    "type": "equal"
  },
  {
    "path": "dist",
    "type": "remove"
  }
]

Diffing File with Snapshots

Diffs of text-based files can be produced with File.diffText and of blob/binary files with File.diffBlob (a text-based file can also be treated as a blob).

The File.diffText can produce two different output formats depending on what is most practical.

For the following example with an initial snapshot-a of src/data.json:

{
  "hello": "world",
  "hola": "mundo",
  "hallo": "welt"
}

And being modified in snapshot-b with the addition of the "bonjour": "le monde",:

{
  "hello": "world",
  "hola": "mundo",
  "bonjour": "le monde",
  "hallo": "welt"
}

Using the "diff-object" format:

const diffs = await sandbox.root
  .at("src/data.json")
  .diffText("snapshot-a", "snapshot-b", "diff-object");

console.log(diffs);

The output of diffs would be an array of text diff objects:

[
  {
    "type": "equal",
    "value": "{\n  \"hello\": \"world\",\n  \"hola\": \"mundo\",\n"
  },
  {
    "type": "add",
    "value": "  \"bonjour\": \"le monde\",\n"
  },
  {
    "type": "equal",
    "value": "  \"hallo\": \"welt\"\n}\n"
  }
]

Using the same example but with alternative format of "patch-string":

const diff = await sandbox.root
  .at("src/data.json")
  .diffText("snapshot-a", "snapshot-b", "patch-string");

console.log(diff);

Now the diff would output the string:

Index: src/data.json
===================================================================
--- src/data.json
+++ src/data.json
@@ -1,5 +1,6 @@
 {
   "hello": "world",
   "hola": "mundo",
+  "bonjour": "le monde",
   "hallo": "welt"
 }

Files treated as blobs can be diffed with File.diffBlob:

const diff = await sandbox.root.at("src/data.json").diffBlob("first", "second");
console.log(diff);

The log from diffBlob would output:

{
  "path": "src/data.json",
  "type": "modify"
}

Additional Notes

Design

fs-testkit is designed to make traversing the filesystem structure as easy as possible by providing pathing APIs that can be chained together. These pathing APIs represent locations within the sandbox root directory, but it’s only when using the async methods on a File or Dir instance that the filesystem is used. In summary, sandbox.at("random/path") might not actually exist, but it can be created with await sandbox.at("random/path").create() or checked to see if it exists with await sandbox.at("random/path").exists().

The Sandbox is guaranteed to have a root on the filesystem after Sandbox.setup is called (which is also called as part of createSandbox). The sandbox root directory separates the files being tested from the tests and source directories which ensures that the operations are contained.

Snapshots use a git implementation. Using a git implementation allows for capturing different states of the filesystem and robustly diffing between these states.

The Sandbox instance accepts an fs argument that is compatible with fs/promises. This argument could be expanded in the future to allow passing other fs-compatible modules or possibly different “fs adapters” that conform to an interface. This would provide additional flexibility and also allow for in-memory options.

To make tests more ergonomic and easier to read there are assertions being developed to be compatible with vitest, jest and chai. This would enable a test to have an assertion like:

await expect(file).not.toExistOnFileSystem();

Motivation

Often test scenarios that use the filesystem are tricky because:

The library tries to address these issues by including: