Skip to content

Commit 047537b

Browse files
cjihrigaduh95
authored andcommitted
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: #56459 Reviewed-By: Matteo Collina <[email protected]> Reviewed-By: James M Snell <[email protected]> Reviewed-By: Chemi Atlow <[email protected]> Reviewed-By: Moshe Atlow <[email protected]>
1 parent e222e36 commit 047537b

File tree

6 files changed

+235
-32
lines changed

6 files changed

+235
-32
lines changed

doc/api/test.md

+37
Original file line numberDiff line numberDiff line change
@@ -3249,6 +3249,43 @@ test('test', (t) => {
32493249
});
32503250
```
32513251

3252+
#### `context.assert.fileSnapshot(value, path[, options])`
3253+
3254+
<!-- YAML
3255+
added: REPLACEME
3256+
-->
3257+
3258+
* `value` {any} A value to serialize to a string. If Node.js was started with
3259+
the [`--test-update-snapshots`][] flag, the serialized value is written to
3260+
`path`. Otherwise, the serialized value is compared to the contents of the
3261+
existing snapshot file.
3262+
* `path` {string} The file where the serialized `value` is written.
3263+
* `options` {Object} Optional configuration options. The following properties
3264+
are supported:
3265+
* `serializers` {Array} An array of synchronous functions used to serialize
3266+
`value` into a string. `value` is passed as the only argument to the first
3267+
serializer function. The return value of each serializer is passed as input
3268+
to the next serializer. Once all serializers have run, the resulting value
3269+
is coerced to a string. **Default:** If no serializers are provided, the
3270+
test runner's default serializers are used.
3271+
3272+
This function serializes `value` and writes it to the file specified by `path`.
3273+
3274+
```js
3275+
test('snapshot test with default serialization', (t) => {
3276+
t.assert.fileSnapshot({ value1: 1, value2: 2 }, './snapshots/snapshot.json');
3277+
});
3278+
```
3279+
3280+
This function differs from `context.assert.snapshot()` in the following ways:
3281+
3282+
* The snapshot file path is explicitly provided by the user.
3283+
* Each snapshot file is limited to a single snapshot value.
3284+
* No additional escaping is performed by the test runner.
3285+
3286+
These differences allow snapshot files to better support features such as syntax
3287+
highlighting.
3288+
32523289
#### `context.assert.snapshot(value[, options])`
32533290

32543291
<!-- YAML

lib/internal/test_runner/snapshot.js

+87-28
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const {
2323
validateArray,
2424
validateFunction,
2525
validateObject,
26+
validateString,
2627
} = require('internal/validators');
2728
const { strictEqual } = require('assert');
2829
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
@@ -109,16 +110,7 @@ class SnapshotFile {
109110
}
110111
this.loaded = true;
111112
} catch (err) {
112-
let msg = `Cannot read snapshot file '${this.snapshotFile}.'`;
113-
114-
if (err?.code === 'ENOENT') {
115-
msg += ` ${kMissingSnapshotTip}`;
116-
}
117-
118-
const error = new ERR_INVALID_STATE(msg);
119-
error.cause = err;
120-
error.filename = this.snapshotFile;
121-
throw error;
113+
throwReadError(err, this.snapshotFile);
122114
}
123115
}
124116

@@ -132,11 +124,7 @@ class SnapshotFile {
132124
mkdirSync(dirname(this.snapshotFile), { __proto__: null, recursive: true });
133125
writeFileSync(this.snapshotFile, output, 'utf8');
134126
} catch (err) {
135-
const msg = `Cannot write snapshot file '${this.snapshotFile}.'`;
136-
const error = new ERR_INVALID_STATE(msg);
137-
error.cause = err;
138-
error.filename = this.snapshotFile;
139-
throw error;
127+
throwWriteError(err, this.snapshotFile);
140128
}
141129
}
142130
}
@@ -171,21 +159,18 @@ class SnapshotManager {
171159

172160
serialize(input, serializers = serializerFns) {
173161
try {
174-
let value = input;
175-
176-
for (let i = 0; i < serializers.length; ++i) {
177-
const fn = serializers[i];
178-
value = fn(value);
179-
}
180-
162+
const value = serializeValue(input, serializers);
181163
return `\n${templateEscape(value)}\n`;
182164
} catch (err) {
183-
const error = new ERR_INVALID_STATE(
184-
'The provided serializers did not generate a string.',
185-
);
186-
error.input = input;
187-
error.cause = err;
188-
throw error;
165+
throwSerializationError(input, err);
166+
}
167+
}
168+
169+
serializeWithoutEscape(input, serializers = serializerFns) {
170+
try {
171+
return serializeValue(input, serializers);
172+
} catch (err) {
173+
throwSerializationError(input, err);
189174
}
190175
}
191176

@@ -222,6 +207,80 @@ class SnapshotManager {
222207
}
223208
};
224209
}
210+
211+
createFileAssert() {
212+
const manager = this;
213+
214+
return function fileSnapshotAssertion(actual, path, options = kEmptyObject) {
215+
validateString(path, 'path');
216+
validateObject(options, 'options');
217+
const {
218+
serializers = serializerFns,
219+
} = options;
220+
validateFunctionArray(serializers, 'options.serializers');
221+
const value = manager.serializeWithoutEscape(actual, serializers);
222+
223+
if (manager.updateSnapshots) {
224+
try {
225+
mkdirSync(dirname(path), { __proto__: null, recursive: true });
226+
writeFileSync(path, value, 'utf8');
227+
} catch (err) {
228+
throwWriteError(err, path);
229+
}
230+
} else {
231+
let expected;
232+
233+
try {
234+
expected = readFileSync(path, 'utf8');
235+
} catch (err) {
236+
throwReadError(err, path);
237+
}
238+
239+
strictEqual(value, expected);
240+
}
241+
};
242+
}
243+
}
244+
245+
function throwReadError(err, filename) {
246+
let msg = `Cannot read snapshot file '${filename}.'`;
247+
248+
if (err?.code === 'ENOENT') {
249+
msg += ` ${kMissingSnapshotTip}`;
250+
}
251+
252+
const error = new ERR_INVALID_STATE(msg);
253+
error.cause = err;
254+
error.filename = filename;
255+
throw error;
256+
}
257+
258+
function throwWriteError(err, filename) {
259+
const msg = `Cannot write snapshot file '${filename}.'`;
260+
const error = new ERR_INVALID_STATE(msg);
261+
error.cause = err;
262+
error.filename = filename;
263+
throw error;
264+
}
265+
266+
function throwSerializationError(input, err) {
267+
const error = new ERR_INVALID_STATE(
268+
'The provided serializers did not generate a string.',
269+
);
270+
error.input = input;
271+
error.cause = err;
272+
throw error;
273+
}
274+
275+
function serializeValue(value, serializers) {
276+
let v = value;
277+
278+
for (let i = 0; i < serializers.length; ++i) {
279+
const fn = serializers[i];
280+
v = fn(v);
281+
}
282+
283+
return v;
225284
}
226285

227286
function validateFunctionArray(fns, name) {

lib/internal/test_runner/test.js

+7-3
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,18 @@ function lazyFindSourceMap(file) {
101101
function lazyAssertObject(harness) {
102102
if (assertObj === undefined) {
103103
const { getAssertionMap } = require('internal/test_runner/assert');
104+
const { SnapshotManager } = require('internal/test_runner/snapshot');
104105

105106
assertObj = getAssertionMap();
106-
if (!assertObj.has('snapshot')) {
107-
const { SnapshotManager } = require('internal/test_runner/snapshot');
107+
harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots);
108108

109-
harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots);
109+
if (!assertObj.has('snapshot')) {
110110
assertObj.set('snapshot', harness.snapshotManager.createAssert());
111111
}
112+
113+
if (!assertObj.has('fileSnapshot')) {
114+
assertObj.set('fileSnapshot', harness.snapshotManager.createFileAssert());
115+
}
112116
}
113117
return assertObj;
114118
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
const { test } = require('node:test');
3+
4+
test('snapshot file path is created', (t) => {
5+
t.assert.fileSnapshot({ baz: 9 }, './foo/bar/baz/1.json');
6+
});
7+
8+
test('test with plan', (t) => {
9+
t.plan(2);
10+
t.assert.fileSnapshot({ foo: 1, bar: 2 }, '2.json');
11+
t.assert.fileSnapshot(5, '3.txt');
12+
});
13+
14+
test('custom serializers are supported', (t) => {
15+
t.assert.fileSnapshot({ foo: 1 }, '4.txt', {
16+
serializers: [
17+
(value) => { return value + '424242'; },
18+
(value) => { return JSON.stringify(value); },
19+
]
20+
});
21+
});

test/parallel/test-runner-assert.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ test('expected methods are on t.assert', (t) => {
1010
'strict',
1111
];
1212
const assertKeys = Object.keys(assert).filter((key) => !uncopiedKeys.includes(key));
13-
const expectedKeys = ['snapshot'].concat(assertKeys).sort();
13+
const expectedKeys = ['snapshot', 'fileSnapshot'].concat(assertKeys).sort();
1414
assert.deepStrictEqual(Object.keys(t.assert).sort(), expectedKeys);
1515
});
1616

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use strict';
2+
const common = require('../common');
3+
const fixtures = require('../common/fixtures');
4+
const tmpdir = require('../common/tmpdir');
5+
const { suite, test } = require('node:test');
6+
7+
tmpdir.refresh();
8+
9+
suite('t.assert.fileSnapshot() validation', () => {
10+
test('path must be a string', (t) => {
11+
t.assert.throws(() => {
12+
t.assert.fileSnapshot({}, 5);
13+
}, /The "path" argument must be of type string/);
14+
});
15+
16+
test('options must be an object', (t) => {
17+
t.assert.throws(() => {
18+
t.assert.fileSnapshot({}, '', null);
19+
}, /The "options" argument must be of type object/);
20+
});
21+
22+
test('options.serializers must be an array if present', (t) => {
23+
t.assert.throws(() => {
24+
t.assert.fileSnapshot({}, '', { serializers: 5 });
25+
}, /The "options\.serializers" property must be an instance of Array/);
26+
});
27+
28+
test('options.serializers must only contain functions', (t) => {
29+
t.assert.throws(() => {
30+
t.assert.fileSnapshot({}, '', { serializers: [() => {}, ''] });
31+
}, /The "options\.serializers\[1\]" property must be of type function/);
32+
});
33+
});
34+
35+
suite('t.assert.fileSnapshot() update/read flow', () => {
36+
const fixture = fixtures.path(
37+
'test-runner', 'snapshots', 'file-snapshots.js'
38+
);
39+
40+
test('fails prior to snapshot generation', async (t) => {
41+
const child = await common.spawnPromisified(
42+
process.execPath,
43+
[fixture],
44+
{ cwd: tmpdir.path },
45+
);
46+
47+
t.assert.strictEqual(child.code, 1);
48+
t.assert.strictEqual(child.signal, null);
49+
t.assert.match(child.stdout, /tests 3/);
50+
t.assert.match(child.stdout, /pass 0/);
51+
t.assert.match(child.stdout, /fail 3/);
52+
t.assert.match(child.stdout, /Missing snapshots can be generated/);
53+
});
54+
55+
test('passes when regenerating snapshots', async (t) => {
56+
const child = await common.spawnPromisified(
57+
process.execPath,
58+
['--test-update-snapshots', fixture],
59+
{ cwd: tmpdir.path },
60+
);
61+
62+
t.assert.strictEqual(child.code, 0);
63+
t.assert.strictEqual(child.signal, null);
64+
t.assert.match(child.stdout, /tests 3/);
65+
t.assert.match(child.stdout, /pass 3/);
66+
t.assert.match(child.stdout, /fail 0/);
67+
});
68+
69+
test('passes when snapshots exist', async (t) => {
70+
const child = await common.spawnPromisified(
71+
process.execPath,
72+
[fixture],
73+
{ cwd: tmpdir.path },
74+
);
75+
76+
t.assert.strictEqual(child.code, 0);
77+
t.assert.strictEqual(child.signal, null);
78+
t.assert.match(child.stdout, /tests 3/);
79+
t.assert.match(child.stdout, /pass 3/);
80+
t.assert.match(child.stdout, /fail 0/);
81+
});
82+
});

0 commit comments

Comments
 (0)