How do you test a Node.js service that operates on the filesystem? The obvious approach is to create real files under /tmp, run the tests, then clean up. But that comes with problems: slow I/O, inconsistent cross-platform behavior, CI permission differences, and file watcher events whose timing you can’t control.
This post dissects a real FileService test suite to show how memfs and a hand-written FakeWatchService solve all of these.
What the Service Looks Like
FileService implements a IFileService interface. It handles directory browsing, file listing (with fuzzy search), file reading/writing, and CRUD operations. It takes two optional external dependencies via constructor injection:
| |
roots: allowed root directorieswatch: optionalWatchServicefor cache invalidation on file changesfsImpl: optionalfsmodule implementation, passed toglob
All three are injected through the constructor β real implementations in production, fakes in tests. This is the foundation of the entire testing strategy.
Replacing the Real Filesystem with memfs
The first step is swapping out node:fs and node:fs/promises entirely:
| |
memfs is a fully in-memory fs implementation. Its API is identical to Node.js’s native fs, but everything happens in memory β no disk touched.
The dynamic import inside vi.mock factory functions is a Vitest requirement, but actual usage of vol and memfs is through top-level imports.
Before each test, vol.fromJSON() declaratively creates the needed file structure:
| |
Benefits:
- Speed: in-memory operations, no disk I/O
- Isolation:
vol.reset()gives you a fresh filesystem β zero cross-test interference - Readability: the JSON shows the entire file structure at a glance
- Cross-platform: no worrying about Windows path separators or
/tmppermissions
Replacing chokidar with FakeWatchService
FileService has an internal caching layer: the first listFiles() call runs glob to scan the directory tree, caching the result. Subsequent calls return the cache until a WatchService event invalidates it.
In production, chokidar watches for file changes, but chokidar events are asynchronous and non-deterministic. There’s no way to precisely control “trigger an event now” in a test.
The solution is a Fake:
| |
This isn’t a mock β it’s a Fake with real behavior. It genuinely manages subscribers, genuinely executes unsubscriptions, and genuinely dispatches events to all callbacks. The only difference is the event source: instead of OS-level inotify/FSEvents, events come from simulate() calls in test code.
For more on the difference between Fakes and Mocks, see DI + Fake + in-memory: Writing Maintainable Frontend Tests.
Three Cache Invalidation Scenarios
With FakeWatchService, cache behavior becomes precisely verifiable.
Scenario 1: No event β cache hit
| |
A file was added via vol.writeFileSync before the second call, but no watch event was fired, so the cache stays valid. The new file doesn’t appear.
Scenario 2: Event fired β cache invalidated
| |
After watch.simulate(), the cache is cleared and the next listFiles() rescans, picking up the new file.
Scenario 3: Concurrent first calls subscribe only once
| |
Two concurrent listFiles() calls should only subscribe once. This verifies the inflight promise deduplication mechanism works correctly.
All three scenarios would be nearly impossible to write reliably with real chokidar β event timing and frequency aren’t controllable.
Security Tests Are First-Class Citizens
Security assertions are distributed across every describe block:
| |
These aren’t in a separate “security test suite” β they live alongside feature tests. Every entry point has its own security verification.
The isInsideRoot boundary tests are worth noting:
| |
/test-root-sibling has /test-root as a string prefix, but it’s not inside the root. The implementation uses path.relative() to handle this correctly, and the test ensures that behavior.
Tests Are Grouped by Behavior
The test file isn’t organized as “one describe per method.” It’s grouped by behavior:
- browseDirectories: full browsing behavior including filtering, sorting, security checks
- listFiles: three pattern modes (empty, trailing slash, fuzzy) plus a dedicated describe for cache invalidation
- readFile: normal reads + path traversal
- mutations: isolated
MROOTenvironment, full CRUD + out-of-bounds rejection - isInsideRoot: pure logic boundary testing
Cache invalidation gets its own describe('cache invalidation via WatchService') because it’s an independent behavioral concern with its own setup (requires FakeWatchService injection).
How to Reuse This Pattern
The core of this strategy is three things:
- memfs replaces fs: applicable to any service using
node:fs. Two lines ofvi.mock, declarative setup withvol.fromJSON() - Hand-written Fakes replace non-deterministic dependencies: file watchers, WebSockets, event emitters β anything async and event-driven benefits from the Fake +
simulate()pattern - Constructor injection makes replacement possible: instead of
new Chokidar()inside the service, inject aWatchServiceinterface. Tests are just a beneficiary of this design
If your project has similar I/O boundaries β filesystem, database, external APIs, message queues β the same approach applies: define an interface, inject the dependency, swap in an in-memory Fake for tests.
This approach is even more effective in a monorepo. When Fakes are extracted into shared packages, both frontend and backend can use the same test doubles with guaranteed behavioral consistency. See Monorepo Shared Fakes: One Test Double from Frontend to Backend.
