How to mock the filesystem¶
Replace filesystem calls in your tests with an in-memory virtual filesystem seeded from a data map.
Declare fs: null in your utest.config.uc mocks table before using any of the patterns below. See How-to: Mock a module with mock.inject() for the setup steps.
Seed virtual files and read them back¶
Pass a data object whose keys are absolute paths and whose values are the file contents as strings. null marks a path as explicitly absent:
import { describe, it, mock, assert, truthy } from 'utest';
describe('fs mock', () => {
it('reads virtual files', () => {
mock.inject('fs', { data: {
'/tmp/config.txt': 'enabled=1',
'/tmp/other.txt': 'data',
'/tmp/deleted.txt': null
}}, (m_fs) => {
assert.match('enabled=1', m_fs.readfile('/tmp/config.txt'));
assert.match(null, m_fs.readfile('/tmp/deleted.txt'));
assert.match(null, m_fs.readfile('/tmp/absent.txt'));
});
});
});
writefile() stores the data so subsequent reads return the new value:
mock.inject('fs', { data: { '/tmp/writable.txt': 'initial' } }, (m_fs) => {
m_fs.writefile('/tmp/writable.txt', 'updated');
assert.match('updated', m_fs.readfile('/tmp/writable.txt'));
});
Writing to a path that was not pre-seeded also works — the path is created in the virtual layer:
mock.inject('fs', { data: { '/tmp/seed.txt': 'seed' } }, (m_fs) => {
m_fs.writefile('/tmp/new.txt', 'created');
assert.match('created', m_fs.readfile('/tmp/new.txt'));
});
List and search virtual directories¶
lsdir() returns the immediate children of a virtual directory. Virtual entries are merged with real entries from the underlying filesystem (unless strict mode is active, which suppresses the real entries):
mock.inject('fs', { data: {
'/tmp/mockdir/file1.txt': '1',
'/tmp/mockdir/file2.txt': '2',
'/tmp/mockdir/subdir/file3.txt': '3'
}}, (m_fs) => {
const list = m_fs.lsdir('/tmp/mockdir');
sort(list);
assert.match(['file1.txt', 'file2.txt', 'subdir'], list);
});
glob() matches virtual paths against a shell-style pattern (see Reference: Proxy Data Models — fs for the wildcard syntax). Real matches are merged with virtual matches unless strict mode suppresses them.
// * matches within one directory level
mock.inject('fs', { data: {
'/tmp/glob/a.txt': 'a',
'/tmp/glob/b.txt': 'b',
'/tmp/glob/c.log': 'c'
}}, (m_fs) => {
const files = m_fs.glob('/tmp/glob/*.txt');
sort(files);
assert.match(['/tmp/glob/a.txt', '/tmp/glob/b.txt'], files);
});
Character classes work as in real glob(3) — [abc], ranges like [0-9], and negation with [!...] or [^...]:
// [12] matches either digit; the .log file and cfg3 are excluded
mock.inject('fs', { data: {
'/etc/cfg1.conf': 'a',
'/etc/cfg2.conf': 'b',
'/etc/cfg3.conf': 'c',
'/etc/notes.log': 'd'
}}, (m_fs) => {
const files = m_fs.glob('/etc/cfg[12].conf');
sort(files);
assert.match(['/etc/cfg1.conf', '/etc/cfg2.conf'], files);
});
** is not a globstar — it matches within a single directory level like *, so name each level explicitly to reach nested files (/a/*/*.cfg).
Check whether a path exists or get its metadata¶
access() returns true for any virtual file or any directory that contains one, null for everything else. stat() returns { size, mtime, type } for virtual files (type: 'file', size in bytes), { size: 0, mtime: 0, type: 'directory' } for virtual directories, or null for absent paths:
mock.inject('fs', { data: { '/tmp/dir/data.txt': 'hello' } }, (m_fs) => {
// virtual file
assert.match(truthy(), m_fs.access('/tmp/dir/data.txt'));
let s = m_fs.stat('/tmp/dir/data.txt');
assert.match(5, s.size);
assert.match('file', s.type);
// virtual directory — inferred from the file path above
assert.match(truthy(), m_fs.access('/tmp/dir'));
assert.match('directory', m_fs.stat('/tmp/dir').type);
// absent path
assert.match(null, m_fs.access('/tmp/absent.txt'));
assert.match(null, m_fs.stat('/tmp/absent.txt'));
});
Move or remove virtual files¶
rename() moves a virtual file by copying the data to the new path and setting the old path to null. unlink() removes a virtual file by setting its path to null, which also removes it from lsdir():
mock.inject('fs', { data: { '/tmp/old.txt': 'content' } }, (m_fs) => {
assert.match(truthy(), m_fs.rename('/tmp/old.txt', '/tmp/new.txt'));
assert.match('content', m_fs.readfile('/tmp/new.txt'));
assert.match(null, m_fs.readfile('/tmp/old.txt'));
});
mock.inject('fs', { data: {
'/tmp/dir/keep.txt': 'keep',
'/tmp/dir/gone.txt': 'gone'
}}, (m_fs) => {
m_fs.unlink('/tmp/dir/gone.txt');
assert.match(['keep.txt'], m_fs.lsdir('/tmp/dir'));
});
Satisfy code that creates directories or changes permissions¶
mkdir() and chmod() are no-ops that return true. They exist so code that creates directories or changes permissions does not fail under the mock:
mock.inject('fs', {}, (m_fs) => {
assert.match(truthy(), m_fs.mkdir('/tmp/newdir', 493));
assert.match(truthy(), m_fs.chmod('/tmp/file.txt', 420));
});
Simulate an error condition¶
error() returns null by default, indicating no error. Supply a behavior override to simulate error states:
mock.inject('fs', { behavior: { error: () => 'ENOENT' } }, (m_fs) => {
assert.match('ENOENT', m_fs.error());
});
Open and read a file line by line with open()¶
Seed the path as you would for readfile. open(path, 'r') returns a handle that supports read('line'), read('all'), and read(n):
mock.inject('fs', { data: { '/var/log/app.log': 'started\nready\n' } }, (m_fs) => {
const f = m_fs.open('/var/log/app.log', 'r');
assert.match('started\n', f.read('line'));
assert.match('ready\n', f.read('line'));
assert.match(null, f.read('line')); // EOF
f.close();
});
In write mode ('w') the handle accumulates content; close() stores the result back into the data map. In append mode ('a') existing content is preserved:
mock.inject('fs', { data: { '/tmp/log.txt': 'first\n' } }, (m_fs) => {
const f = m_fs.open('/tmp/log.txt', 'a');
f.write('second\n');
f.close();
assert.match('first\nsecond\n', m_fs.readfile('/tmp/log.txt'));
});
Opening an unmocked path in read mode returns null in non-strict mode. If strict mode is active it dies instead.
Pipe command output with popen()¶
Seed the command string (exactly as passed to popen) under the commands key — a separate channel from file paths so that command strings never appear in lsdir, glob, access, or stat:
mock.inject('fs', { commands: { 'ip -j route show': '[{"dst":"default"}]\n' } }, (m_fs) => {
const f = m_fs.popen('ip -j route show', 'r');
assert.match('[{"dst":"default"}]\n', f.read('all'));
f.close();
});
In write mode, the handle stores written content under the command string in the commands channel on close(). Read it back by calling popen again in read mode:
mock.inject('fs', { commands: {} }, (m_fs) => {
const f = m_fs.popen('cat > /tmp/sink', 'w');
f.write('piped data');
f.close();
const r = m_fs.popen('cat > /tmp/sink', 'r');
assert.match('piped data', r.read('all'));
r.close();
});
In strict mode, popen() on an unmocked command dies with a strict mock: error.
Next steps¶
- Control what happens on unmocked paths: How-to: Use strict mode
- Intercept calls through the imported
fsbinding: How-to: Patch global state with mock.global.patch() - See the full
datakey format and behavior override list: Reference: Proxy Data Models — fs