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.0

Install 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:

writeManifest.ts
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:

writeManifest.test.ts
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:

parents.test.ts
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:

fixtures.test.ts
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:

write-count.test.ts
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:

selected-failure.test.ts
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:

scoped-spy.test.ts
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:

permissions.test.ts
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:

core-permissions.test.ts
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:

capacity.test.ts
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:

PromiseAssertion
The output contains the requested versionRead and parse the output file
Cleanup removes the seeded treeAssert that the tree exists before cleanup and is absent afterward
The output bytes are unchangedCompare readFile results before and after the action
The build skips a write methodCount 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.