Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test_runner: add t.assert.fileSnapshot() #56459

Merged
merged 1 commit into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
10 changes: 7 additions & 3 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
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/);
});
});
Loading