fs-testkit provides testing utilities to write better tests that use the file
system
Features:
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
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.
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" }];
SandboxSandboxThe 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
}
}
prettier (default: false) - sets up the default option for prettier when
creating files for supported file types.
autoCleanUp - This defaults to true when a root.path is not
specified. If a root.path is specified then autoCleanUp must be explicitly
set to true and root.allowDestroyRoot must be also set to true in
order for the sandbox root to be automatically cleaned up. See
Cleaning Up a Sandbox for more information.
root.path - specifies the directory to be used for the root directory of the
sandbox. If this is not passed in then a directory is automatically created in
the operating system’s temp directory. This value can be retrieved later from
sandbox.rootPath.
root.allowExisting - If the root directory specified by the root.path
option already exists and should be used this must be set to true in order
to use it as the sandbox root.
root.allowDestroyRoot - This defaults to true if root.path is
unspecified. If root.path is specified then the root.allowDestroyRoot
option must be set to true in order for auto cleanup or to use destroy
method. See Cleaning Up a Sandbox for more
information.
root DirectoryThe 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!");
}
`);
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.
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.
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();
SandboxManual 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:
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.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.DirThe 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.
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");
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,
},
}
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 PropertiesDir instances have the following properties:
See the Dir API documentation for more.
Dir Filesystem OperationsDir instances support the following filesystem operations:
See the Dir API documentation for the full
set of methods available.
Dir can be diffed using snapshots. See:
Diffing Dir with Snapshots
FileThe 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 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.
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 PropertiesThe following properties exist on instances of File:
See the API documentation for more details.
File Filesystem OperationsSee the API documentation for more details.
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 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.
Snapshots can be deleted by name:
await sandbox.snapshot.delete("name of snapshot");
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");
Dir with SnapshotsDir.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"
}
]
File with SnapshotsDiffs 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"
}
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();
Often test scenarios that use the filesystem are tricky because:
The library tries to address these issues by including: