Skip to content

Commit

Permalink
test_runner: add t.assert.fileSnapshot()
Browse files Browse the repository at this point in the history
This commit adds a t.assert.fileSnapshot() API to the test runner.
This is similar to how snapshot tests work in core, as well as
userland options such as toMatchFileSnapshot().
  • Loading branch information
cjihrig committed Jan 3, 2025
1 parent 01554f3 commit e76e07b
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 29 deletions.
37 changes: 37 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -3255,6 +3255,43 @@ test('test', (t) => {
});
```

#### `context.assert.fileSnapshot(value, path[, options])`

<!-- YAML
added: REPLACEME
-->

* `value` {any} A value to serialize to a string. If Node.js was started with
the [`--test-update-snapshots`][] flag, the serialized value is written to
`path`. Otherwise, the serialized value is compared to the contents of the
existing snapshot file.
* `path` {string} The file where the serialized `value` is written.
* `options` {Object} Optional configuration options. The following properties
are supported:
* `serializers` {Array} An array of synchronous functions used to serialize
`value` into a string. `value` is passed as the only argument to the first
serializer function. The return value of each serializer is passed as input
to the next serializer. Once all serializers have run, the resulting value
is coerced to a string. **Default:** If no serializers are provided, the
test runner's default serializers are used.

This function serializes `value` and writes it to the file specified by `path`.

```js
test('snapshot test with default serialization', (t) => {
t.assert.fileSnapshot({ value1: 1, value2: 2 }, './snapshots/snapshot.json');
});
```

This function differs from `context.assert.snapshot()` in the following ways:

* The snapshot file path is explicitly provided by the user.
* Each snapshot file is limited to a single snapshot value.
* No additional escaping is performed by the test runner.

These differences allow snapshot files to better support features such as syntax
highlighting.

#### `context.assert.snapshot(value[, options])`

<!-- YAML
Expand Down
115 changes: 87 additions & 28 deletions lib/internal/test_runner/snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const {
validateArray,
validateFunction,
validateObject,
validateString,
} = require('internal/validators');
const { strictEqual } = require('assert');
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
Expand Down Expand Up @@ -109,16 +110,7 @@ class SnapshotFile {
}
this.loaded = true;
} catch (err) {
let msg = `Cannot read snapshot file '${this.snapshotFile}.'`;

if (err?.code === 'ENOENT') {
msg += ` ${kMissingSnapshotTip}`;
}

const error = new ERR_INVALID_STATE(msg);
error.cause = err;
error.filename = this.snapshotFile;
throw error;
throwReadError(err, this.snapshotFile);
}
}

Expand All @@ -132,11 +124,7 @@ class SnapshotFile {
mkdirSync(dirname(this.snapshotFile), { __proto__: null, recursive: true });
writeFileSync(this.snapshotFile, output, 'utf8');
} catch (err) {
const msg = `Cannot write snapshot file '${this.snapshotFile}.'`;
const error = new ERR_INVALID_STATE(msg);
error.cause = err;
error.filename = this.snapshotFile;
throw error;
throwWriteError(err, this.snapshotFile);
}
}
}
Expand Down Expand Up @@ -171,21 +159,18 @@ class SnapshotManager {

serialize(input, serializers = serializerFns) {
try {
let value = input;

for (let i = 0; i < serializers.length; ++i) {
const fn = serializers[i];
value = fn(value);
}

const value = serializeValue(input, serializers);
return `\n${templateEscape(value)}\n`;
} catch (err) {
const error = new ERR_INVALID_STATE(
'The provided serializers did not generate a string.',
);
error.input = input;
error.cause = err;
throw error;
throwSerializationError(input, err);
}
}

serializeWithoutEscape(input, serializers = serializerFns) {
try {
return serializeValue(input, serializers);
} catch (err) {
throwSerializationError(input, err);
}
}

Expand Down Expand Up @@ -222,6 +207,80 @@ class SnapshotManager {
}
};
}

createFileAssert() {
const manager = this;

return function fileSnapshotAssertion(actual, path, options = kEmptyObject) {
validateString(path, 'path');
validateObject(options, 'options');
const {
serializers = serializerFns,
} = options;
validateFunctionArray(serializers, 'options.serializers');
const value = manager.serializeWithoutEscape(actual, serializers);

if (manager.updateSnapshots) {
try {
mkdirSync(dirname(path), { __proto__: null, recursive: true });
writeFileSync(path, value, 'utf8');
} catch (err) {
throwWriteError(err, path);
}
} else {
let expected;

try {
expected = readFileSync(path, 'utf8');
} catch (err) {
throwReadError(err, path);
}

strictEqual(value, expected);
}
};
}
}

function throwReadError(err, filename) {
let msg = `Cannot read snapshot file '${filename}.'`;

if (err?.code === 'ENOENT') {
msg += ` ${kMissingSnapshotTip}`;
}

const error = new ERR_INVALID_STATE(msg);
error.cause = err;
error.filename = filename;
throw error;
}

function throwWriteError(err, filename) {
const msg = `Cannot write snapshot file '${filename}.'`;
const error = new ERR_INVALID_STATE(msg);
error.cause = err;
error.filename = filename;
throw error;
}

function throwSerializationError(input, err) {
const error = new ERR_INVALID_STATE(
'The provided serializers did not generate a string.',
);
error.input = input;
error.cause = err;
throw error;
}

function serializeValue(value, serializers) {
let v = value;

for (let i = 0; i < serializers.length; ++i) {
const fn = serializers[i];
v = fn(v);
}

return v;
}

function validateFunctionArray(fns, name) {
Expand Down
1 change: 1 addition & 0 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ function lazyAssertObject(harness) {

harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots);
assertObj.set('snapshot', harness.snapshotManager.createAssert());
assertObj.set('fileSnapshot', harness.snapshotManager.createFileAssert());
}
return assertObj;
}
Expand Down
21 changes: 21 additions & 0 deletions test/fixtures/test-runner/snapshots/file-snapshots.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';
const { test } = require('node:test');

test('snapshot file path is created', (t) => {
t.assert.fileSnapshot({ baz: 9 }, './foo/bar/baz/1.json');
});

test('test with plan', (t) => {
t.plan(2);
t.assert.fileSnapshot({ foo: 1, bar: 2 }, '2.json');
t.assert.fileSnapshot(5, '3.txt');
});

test('custom serializers are supported', (t) => {
t.assert.fileSnapshot({ foo: 1 }, '4.txt', {
serializers: [
(value) => { return value + '424242'; },
(value) => { return JSON.stringify(value); },
]
});
});
2 changes: 1 addition & 1 deletion test/parallel/test-runner-assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ test('expected methods are on t.assert', (t) => {
'strict',
];
const assertKeys = Object.keys(assert).filter((key) => !uncopiedKeys.includes(key));
const expectedKeys = ['snapshot'].concat(assertKeys).sort();
const expectedKeys = ['snapshot', 'fileSnapshot'].concat(assertKeys).sort();
assert.deepStrictEqual(Object.keys(t.assert).sort(), expectedKeys);
});

Expand Down
82 changes: 82 additions & 0 deletions test/parallel/test-runner-snapshot-file-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use strict';
const common = require('../common');
const fixtures = require('../common/fixtures');
const tmpdir = require('../common/tmpdir');
const { suite, test } = require('node:test');

tmpdir.refresh();

suite('t.assert.fileSnapshot() validation', () => {
test('path must be a string', (t) => {
t.assert.throws(() => {
t.assert.fileSnapshot({}, 5);
}, /The "path" argument must be of type string/);
});

test('options must be an object', (t) => {
t.assert.throws(() => {
t.assert.fileSnapshot({}, '', null);
}, /The "options" argument must be of type object/);
});

test('options.serializers must be an array if present', (t) => {
t.assert.throws(() => {
t.assert.fileSnapshot({}, '', { serializers: 5 });
}, /The "options\.serializers" property must be an instance of Array/);
});

test('options.serializers must only contain functions', (t) => {
t.assert.throws(() => {
t.assert.fileSnapshot({}, '', { serializers: [() => {}, ''] });
}, /The "options\.serializers\[1\]" property must be of type function/);
});
});

suite('t.assert.fileSnapshot() update/read flow', () => {
const fixture = fixtures.path(
'test-runner', 'snapshots', 'file-snapshots.js'
);

test('fails prior to snapshot generation', async (t) => {
const child = await common.spawnPromisified(
process.execPath,
[fixture],
{ cwd: tmpdir.path },
);

t.assert.strictEqual(child.code, 1);
t.assert.strictEqual(child.signal, null);
t.assert.match(child.stdout, /tests 3/);
t.assert.match(child.stdout, /pass 0/);
t.assert.match(child.stdout, /fail 3/);
t.assert.match(child.stdout, /Missing snapshots can be generated/);
});

test('passes when regenerating snapshots', async (t) => {
const child = await common.spawnPromisified(
process.execPath,
['--test-update-snapshots', fixture],
{ cwd: tmpdir.path },
);

t.assert.strictEqual(child.code, 0);
t.assert.strictEqual(child.signal, null);
t.assert.match(child.stdout, /tests 3/);
t.assert.match(child.stdout, /pass 3/);
t.assert.match(child.stdout, /fail 0/);
});

test('passes when snapshots exist', async (t) => {
const child = await common.spawnPromisified(
process.execPath,
[fixture],
{ cwd: tmpdir.path },
);

t.assert.strictEqual(child.code, 0);
t.assert.strictEqual(child.signal, null);
t.assert.match(child.stdout, /tests 3/);
t.assert.match(child.stdout, /pass 3/);
t.assert.match(child.stdout, /fail 0/);
});
});

0 comments on commit e76e07b

Please sign in to comment.