test_runner: add t.assert.fileSnapshot()

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().

PR-URL: https://github.com/nodejs/node/pull/56459
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
This commit is contained in:
Colin Ihrig 2025-01-09 16:12:17 -05:00 committed by GitHub
parent 24ed8da48e
commit 19c8cc12ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 235 additions and 32 deletions

View File

@ -3280,6 +3280,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

View File

@ -23,6 +23,7 @@ const {
validateArray,
validateFunction,
validateObject,
validateString,
} = require('internal/validators');
const { strictEqual } = require('assert');
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
@ -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);
}
}
@ -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);
}
}
}
@ -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);
}
}
@ -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) {

View File

@ -101,14 +101,18 @@ function lazyFindSourceMap(file) {
function lazyAssertObject(harness) {
if (assertObj === undefined) {
const { getAssertionMap } = require('internal/test_runner/assert');
const { SnapshotManager } = require('internal/test_runner/snapshot');
assertObj = getAssertionMap();
if (!assertObj.has('snapshot')) {
const { SnapshotManager } = require('internal/test_runner/snapshot');
harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots);
harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots);
if (!assertObj.has('snapshot')) {
assertObj.set('snapshot', harness.snapshotManager.createAssert());
}
if (!assertObj.has('fileSnapshot')) {
assertObj.set('fileSnapshot', harness.snapshotManager.createFileAssert());
}
}
return assertObj;
}

View File

@ -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); },
]
});
});

View File

@ -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);
});

View File

@ -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/);
});
});