Test with an isolated filesystem
Replace temporary directories and filesystem stubs with a fresh in-memory volume. Run the action and its assertions in one provided Effect so the test checks the files the program changed.
The code under test must request Effect's FileSystem.FileSystem service. The adapter cannot replace direct calls
to node:fs or filesystem access from child processes.
Install the test dependencies
Install the core volume, memory adapter, and matching Effect test integration with Vitest 5:
npm install @effect-vfs/core@latest @effect-vfs/memory@latest
npm install --save-dev "@effect/vitest@$(npm view @effect-vfs/memory peerDependencies.effect)" "@effect/platform-bun@$(npm view @effect-vfs/memory peerDependencies.effect)" vitest@^5.0.0Install Bun before running these examples, and use an ES module project. The examples run under Bun and provide BunCrypto.layer because volume construction
requires Effect's Crypto.Crypto service. For another runtime, provide its matching Effect Crypto implementation.
See Package compatibility for version requirements.
Keep production code independent of storage
Save this function in writeManifest.ts. It requests a filesystem but leaves the implementation to its caller:
import { Effect, FileSystem } from "effect"
export const writeManifest = Effect.fn("writeManifest")(function* (version: string) {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory("/dist", { recursive: true })
yield* fs.writeFileString("/dist/manifest.json", JSON.stringify({ version }))
return yield* fs.readFileString("/dist/manifest.json")
})Production can provide a host filesystem. Tests can provide disposable state without changing the function.
Run the action and assertions against one volume
Save the following test beside writeManifest.ts. Import the test API from @effect/vitest and return an Effect
from it.effect:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { MemoryFileSystem } from "@effect-vfs/memory"
import { assert, describe, it } from "@effect/vitest"
import { Effect, FileSystem } from "effect"
import { writeManifest } from "./writeManifest.js"
describe("writeManifest", () => {
it.effect("should write the requested version when the filesystem is empty", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* writeManifest("1.2.3")
const output = JSON.parse(yield* fs.readFileString("/dist/manifest.json"))
assert.deepStrictEqual(output, { version: "1.2.3" })
}).pipe(Effect.provide(MemoryFileSystem.layer), Effect.provide(BunCrypto.layer))
)
it.effect("should start without another test's files when a fresh layer is provided", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
assert.isFalse(yield* fs.exists("/dist/manifest.json"))
}).pipe(Effect.provide(MemoryFileSystem.layer), Effect.provide(BunCrypto.layer))
)
})Run these tests with bun run --bun vitest run. The first test reads the file written by the application, rather
than checking only its returned value. Each test provides its own filesystem layer, so the second starts without
that file.
Keep one Effect.provide(MemoryFileSystem.layer) around both the action and the assertions. Separately building
the layer for a write and a later read creates different volumes, even if both builds use the same layer value.
it.layer builds its layer once for its test group. Tests in that group share a volume. Use a separate provide
for each test that needs independent state. Within one layer graph, repeated use of the same layer value shares
its build. Use Layer.fresh when part of that graph needs an independent build.
Create the parent directories your test needs
A temporary-directory setup may have created a parent directory without the test stating that precondition. Ordinary writes do not create missing parents. Make the required directory explicit:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { MemoryFileSystem } from "@effect-vfs/memory"
import { assert, it } from "@effect/vitest"
import { Effect, FileSystem } from "effect"
it.effect("should write metadata after its parent directory is created", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const error = yield* Effect.flip(fs.writeFileString("/output/_meta.json", "{}"))
assert.strictEqual(error.reason._tag, "NotFound")
yield* fs.makeDirectory("/output", { recursive: true })
yield* fs.writeFileString("/output/_meta.json", "{}")
assert.strictEqual(yield* fs.readFileString("/output/_meta.json"), "{}")
}).pipe(Effect.provide(MemoryFileSystem.layer), Effect.provide(BunCrypto.layer))
)Create parents in test setup when the application expects them to exist. Keep directory creation in production
code when creating those directories is part of the application's job, as it is in writeManifest.
Seed the preconditions your test asserts
Use MemoryFileSystem.layerFromFixture to provide Effect's filesystem service from a declared tree. Each independent
layer build starts from the same fixture with fresh storage:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { MemoryFileSystem } from "@effect-vfs/memory"
import { assert, it } from "@effect/vitest"
import { Effect, FileSystem } from "effect"
const seededFileSystem = MemoryFileSystem.layerFromFixture({
entries: [
{ kind: "directory", path: "/project" },
{
kind: "file",
path: "/project/package.json",
bytes: new TextEncoder().encode('{"version":"1.2.3"}')
},
{ kind: "directory", path: "/cache" },
{ kind: "file", path: "/cache/stale.json", bytes: new TextEncoder().encode("{}") }
]
})
const readPackageVersion = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const manifest = JSON.parse(yield* fs.readFileString("/project/package.json"))
return manifest.version
})
const pruneCache = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.remove("/cache", { recursive: true })
})
it.effect("should parse the version from the seeded package bytes", () =>
Effect.gen(function* () {
assert.strictEqual(yield* readPackageVersion, "1.2.3")
}).pipe(Effect.provide(seededFileSystem), Effect.provide(BunCrypto.layer))
)
it.effect("should remove the cache when it contains a stale file", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
assert.isTrue(yield* fs.exists("/cache/stale.json"))
yield* pruneCache
assert.isFalse(yield* fs.exists("/cache"))
}).pipe(Effect.provide(seededFileSystem), Effect.provide(BunCrypto.layer))
)Each test builds the seeded layer independently. The parsing test reads real fixture bytes instead of a canned
parser result. The removal test proves that cleanup removes an existing tree. A successful stubbed remove call
cannot prove that a tree existed or disappeared.
Declare fixture parents explicitly. MemoryFileSystem.make and layer start with an empty /tmp directory.
bind leaves the supplied volume unchanged. Include { kind: "directory", path: "/tmp" } in a fixture when its
tests need the default temporary directory.
layerFromFixture adds no entries to your fixture. See the
MemoryFileSystem reference for constructor options and
Build and transport filesystem snapshots for fixture rules.
If the application also requests Path.Path, merge Path.layer with the filesystem layer. Path.layer supplies
POSIX paths in Effect 4.
Count writes and inject selected failures
Use FileSystemTesting.make when a test needs to observe a call or reject one selected operation. Unhandled
calls use the backing filesystem. A handler succeeds to allow the real operation and fails with PlatformError
to prevent it. A successful handler cannot replace the operation's result.
Allocate counters inside the factory. This test proves that an unchanged second build makes no write call:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { FileSystemTesting, MemoryFileSystem } from "@effect-vfs/memory"
import { assert, it } from "@effect/vitest"
import { Effect, FileSystem, Ref } from "effect"
const buildManifest = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = "/manifest.json"
const content = '{"version":"1.2.3"}'
if (yield* fs.exists(path)) {
if ((yield* fs.readFileString(path)) === content) return
}
yield* fs.writeFileString(path, content)
})
it.effect("should skip the write when the manifest has not changed", () =>
Effect.gen(function* () {
const base = yield* MemoryFileSystem.make
const { fileSystem, state } = yield* FileSystemTesting.make(base, Effect.fnUntraced(function* () {
const writes = yield* Ref.make(0)
return {
state: writes,
handlers: { writeFile: () => Ref.update(writes, (count) => count + 1) }
}
}))
const build = buildManifest.pipe(Effect.provideService(FileSystem.FileSystem, fileSystem))
yield* build
assert.strictEqual(yield* Ref.get(state), 1)
yield* build
assert.strictEqual(yield* Ref.get(state), 1)
assert.strictEqual(yield* base.readFileString("/manifest.json"), '{"version":"1.2.3"}')
}).pipe(Effect.provide(BunCrypto.layer))
)writeFileString routes through the writeFile handler. Handlers run when the Effect executes, so constructing
a write does not increment the counter. Executing the same write Effect twice increments it twice.
To inspect successful output after a selected failure, reject the next write before it reaches the real filesystem:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { FileSystemTesting, MemoryFileSystem } from "@effect-vfs/memory"
import { assert, it } from "@effect/vitest"
import { Effect, PlatformError, Ref } from "effect"
it.effect("should keep earlier output when the second write fails", () =>
Effect.gen(function* () {
const base = yield* MemoryFileSystem.make
const failure = PlatformError.systemError({
_tag: "Unknown", module: "FileSystem", method: "writeFile",
pathOrDescriptor: "/second", description: "Injected failure"
})
const { fileSystem } = yield* FileSystemTesting.make(base, Effect.fnUntraced(function* () {
const writes = yield* Ref.make(0)
return {
state: writes,
handlers: {
writeFile: () => Ref.updateAndGet(writes, (count) => count + 1).pipe(
Effect.flatMap((count) => count === 2 ? Effect.fail(failure) : Effect.void)
)
}
}
}))
yield* fileSystem.writeFileString("/first", "completed")
assert.strictEqual(yield* Effect.flip(fileSystem.writeFileString("/second", "rejected")), failure)
assert.strictEqual(yield* base.readFileString("/first"), "completed")
assert.isFalse(yield* base.exists("/second"))
}).pipe(Effect.provide(BunCrypto.layer))
)Separate constructor executions allocate separate factory state, even when they wrap the same base filesystem.
The base filesystem's files remain shared in that case. Sharing one built service shares its counters. A layer
such as Layer.effect(FileSystem.FileSystem, FileSystemTesting.make(base, factory).pipe(Effect.map((built) => built.fileSystem)))
uses normal Layer memoization. Use Layer.fresh to rebuild it. A counter captured outside the factory remains
shared even across fresh builds.
Observe scoped acquisition
Streams and sinks call the open handler only when consumed. Their chunk reads and writes use unchanged file
handles, so a writeFile spy does not count sink writes. Scoped calls keep their original scope requirements:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { FileSystemTesting, MemoryFileSystem } from "@effect-vfs/memory"
import { assert, it } from "@effect/vitest"
import { Effect, Ref, Stream } from "effect"
it.effect("should observe stream acquisition and clean up a scoped temporary file", () =>
Effect.gen(function* () {
const base = yield* MemoryFileSystem.make
const { fileSystem, state } = yield* FileSystemTesting.make(base, Effect.fnUntraced(function* () {
const opens = yield* Ref.make(0)
return { state: opens, handlers: { open: () => Ref.update(opens, (count) => count + 1) } }
}))
const path = yield* Effect.scoped(Effect.gen(function* () {
const path = yield* fileSystem.makeTempFileScoped()
yield* fileSystem.writeFileString(path, "temporary")
const stream = fileSystem.stream(path)
assert.strictEqual(yield* Ref.get(state), 0)
yield* Stream.runDrain(stream)
assert.strictEqual(yield* Ref.get(state), 1)
return path
}))
assert.isFalse(yield* base.exists(path))
}).pipe(Effect.provide(BunCrypto.layer))
)The decorator rebuilds exists, string reads and writes, streams, and sinks with Effect's standard
FileSystem.make helpers. Custom implementations of those helpers on the supplied service are replaced.
watch is forwarded unchanged. Internal operations in the backing service, including temporary-resource
cleanup and writes performed by copying, do not re-enter unrelated handlers. Spies count documented public
calls rather than every state mutation.
See the FileSystemTesting reference for handler arguments. Rejection before
delegation does not simulate a partial write or a crash. Use actual filesystem rules for permission and capacity
tests, as described below.
Use real filesystem failures
Use filesystem rules when those rules cause the failure you need to test. Set caller credentials for a permission failure or volume limits for a capacity failure.
Test permissions with an unprivileged caller
Bind the volume with an unprivileged identity to test an application that requests FileSystem.FileSystem:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { VirtualFileSystem as Vfs } from "@effect-vfs/core"
import * as Testing from "@effect-vfs/core/Testing"
import { MemoryFileSystem } from "@effect-vfs/memory"
import { assert, it } from "@effect/vitest"
import { Effect, FileSystem } from "effect"
const listPrivateDirectory = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
return yield* fs.readDirectory("/private")
})
it.effect("should deny a guest access to a private directory", () =>
Effect.gen(function* () {
const volume = yield* Vfs.Volume
yield* (yield* Vfs.Caller).mkdir("/private", { mode: 0o700 })
const fs = yield* MemoryFileSystem.bind(volume, {
identity: { uid: 1000, gid: 1000, groups: [], privileged: false }
})
const error = yield* Effect.flip(
listPrivateDirectory.pipe(Effect.provideService(FileSystem.FileSystem, fs))
)
assert.strictEqual(error.reason._tag, "PermissionDenied")
}).pipe(Effect.provide(Testing.layer()), Effect.provide(BunCrypto.layer))
)The default binding is privileged, so changing a directory's mode alone does not make that binding fail. The test binds the existing volume with guest credentials and supplies that service to the application.
Test code that uses core callers
For code that uses core callers directly, use Testing.callerAs on the same volume. It returns a core caller,
not a FileSystem service. Core errors use AccessDenied; the adapter translates that code to PermissionDenied:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { VirtualFileSystem as Vfs } from "@effect-vfs/core"
import * as Testing from "@effect-vfs/core/Testing"
import { assert, it } from "@effect/vitest"
import { Effect } from "effect"
it.effect("should deny a core guest access to a private directory", () =>
Effect.gen(function* () {
yield* (yield* Vfs.Caller).mkdir("/private", { mode: 0o700 })
const guest = yield* Testing.callerAs({ uid: 1000, gid: 1000, groups: [], privileged: false })
const error = yield* Effect.flip(guest.readDirectory("/private"))
assert.strictEqual(error.code, "AccessDenied")
}).pipe(Effect.provide(Testing.layer()), Effect.provide(BunCrypto.layer))
)Test capacity with a volume limit
Set maxBytes to limit the volume's combined file and symbolic-link bytes. This adapter test attempts a write
larger than its one-byte capacity:
import * as BunCrypto from "@effect/platform-bun/BunCrypto"
import { VirtualFileSystem as Vfs } from "@effect-vfs/core"
import * as Testing from "@effect-vfs/core/Testing"
import { MemoryFileSystem } from "@effect-vfs/memory"
import { assert, it } from "@effect/vitest"
import { Effect } from "effect"
import * as ByteSize from "effect/ByteSize"
it.effect("should reject a write that exceeds the volume's capacity", () =>
Effect.gen(function* () {
const fs = yield* MemoryFileSystem.bind(yield* Vfs.Volume)
const error = yield* Effect.flip(fs.writeFileString("/output.txt", "too large"))
assert.strictEqual(error.reason._tag, "Unknown")
assert.strictEqual(error.reason.description, "NoSpace")
}).pipe(
Effect.provide(Testing.layer({ volume: { maxBytes: ByteSize.bytes(1) } })),
Effect.provide(BunCrypto.layer)
)
)The core reports NoSpace. Effect's filesystem error tags have no capacity-specific tag, so the adapter reports
Unknown with the description NoSpace. See Volume capacity limits
for the available limits and the Testing reference for test-layer options.
Choose when to inject a failure
To make a selected write fail independently of permissions or capacity, inject a failure for that operation. For example, fail only the second write while the filesystem otherwise remains writable. Delegate other operations to the real filesystem so earlier writes still change its state.
Use FileSystemTesting.make to reject the selected operation
before delegation. Assert that earlier writes remain in the filesystem and that the rejected write changes nothing.
Choose an assertion that proves the promise
Choose evidence that matches the behavior under test:
| Promise | Assertion |
|---|---|
| The output contains the requested version | Read and parse the output file |
| Cleanup removes the seeded tree | Assert that the tree exists before cleanup and is absent afterward |
| The output bytes are unchanged | Compare readFile results before and after the action |
| The build skips a write method | Count calls to that method during each build |
For an unchanged-output test, byte equality proves the content promise. To compare a complete tree, use
diffSnapshots and inspectSnapshotDelta.
Delta inspection excludes timestamp-only changes by default. Pass { includeTimestamps: true } when timestamps
are part of the promised state.
For a skipped-write test, observe the method the application calls. If the first build writes once and the second skips that method, assert one call during the first build and zero during the second. Equal bytes do not prove that no write occurred.
Other observations have narrower meanings:
- Equal modification times can hide a rewrite when clock resolution is too coarse to distinguish the writes.
In
it.effect, the test clock can also stay unchanged between writes. - Core revisions describe committed mutations, not every attempted call.
- An overlay's final differences describe final state. A write followed by restoration can leave no content difference.
When you spread an existing filesystem service and replace writeFile, its existing writeFileString helper
still calls the original implementation. To observe string writes, wrap writeFileString itself, or rebuild the
derived helpers with Effect's FileSystem.make after wrapping the base method. A writeFile counter does not
count writes through open file handles or sinks. Choose the method that matches the promise.
Let the test scope release resources
it.effect runs the returned Effect, provides Effect's test services, and keeps a scope open for the test.
Do not call Effect.runPromise or Effect.runSync inside the test. Open files, temporary resources, and watch
subscriptions can use the test scope without another Effect.scoped around the body.
Keep explicit cleanup in a test when cleanup itself is the behavior being asserted. Otherwise, let the test scope release resources when the returned Effect completes.
Check which code can still access the host
The memory adapter replaces Effect's FileSystem service. It cannot intercept code that directly uses:
node:fs;- child processes;
- native extensions; or
- another API that accesses the host filesystem independently.
Keep host-backed tests for those paths. Memory tests alone do not prove that another adapter or operating system has the same behavior. The shared contract runner compares memory with Effect's Node adapter under Node 24.21.0 on Ubuntu 24.04. It checks common filesystem behavior and resource cleanup while preserving explicit differences in copy collisions, timestamps, ownership, and error attribution. This qualification does not establish support for other host runtimes or operating systems.
Read Volumes, callers, and handles for state ownership and caller authority. Use an overlay workspace when a test or preview starts from an immutable snapshot and needs its final changes.