Skip to content

Commit 67a15b1

Browse files
committed
test_runner: add snapshot testing
This commit adds a t.assert.snapshot() method that implements snapshot testing.
1 parent b7faaaa commit 67a15b1

File tree

11 files changed

+628
-4
lines changed

11 files changed

+628
-4
lines changed

lib/internal/test_runner/harness.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ function setup(root) {
210210
counters: null,
211211
shouldColorizeTestFiles: false,
212212
teardown: exitHandler,
213+
snapshotManager: null,
213214
};
214215
root.harness.resetCounters();
215216
root.startTime = hrtime();
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
'use strict';
2+
const {
3+
ArrayPrototypeJoin,
4+
ArrayPrototypeMap,
5+
ArrayPrototypeSort,
6+
JSONStringify,
7+
ObjectKeys,
8+
SafeMap,
9+
String,
10+
StringPrototypeReplaceAll,
11+
} = primordials;
12+
const {
13+
codes: {
14+
ERR_INVALID_STATE,
15+
},
16+
} = require('internal/errors');
17+
const { emitExperimentalWarning, kEmptyObject } = require('internal/util');
18+
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
19+
debug = fn;
20+
});
21+
const {
22+
validateArray,
23+
validateFunction,
24+
validateObject,
25+
} = require('internal/validators');
26+
const { strictEqual } = require('assert');
27+
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
28+
const { dirname } = require('path');
29+
const { createContext, runInContext } = require('vm');
30+
const kExperimentalWarning = 'Snapshot testing';
31+
const kMissingSnapshotTip = 'Missing snapshots can be generated by rerunning ' +
32+
'the command with the --test-update-snapshots flag.';
33+
const defaultSerializers = [
34+
(value) => { return JSONStringify(value, null, 2); },
35+
];
36+
37+
function defaultResolveSnapshotPath(testPath) {
38+
if (typeof testPath !== 'string') {
39+
return testPath;
40+
}
41+
42+
return `${testPath}.snapshot`;
43+
}
44+
45+
let resolveSnapshotPathFn = defaultResolveSnapshotPath;
46+
let serializerFns = defaultSerializers;
47+
48+
function setResolveSnapshotPath(fn) {
49+
emitExperimentalWarning(kExperimentalWarning);
50+
validateFunction(fn, 'fn');
51+
resolveSnapshotPathFn = fn;
52+
}
53+
54+
function setDefaultSerializers(serializers) {
55+
emitExperimentalWarning(kExperimentalWarning);
56+
validateFunctionArray(serializers, 'serializers');
57+
serializerFns = ArrayPrototypeMap(serializers, (fn) => {
58+
return fn;
59+
});
60+
}
61+
62+
class SnapshotManager {
63+
constructor(entryFile, updateSnapshots) {
64+
this.entryFile = entryFile;
65+
this.snapshotFile = undefined;
66+
this.snapshots = { __proto__: null };
67+
this.nameCounts = new SafeMap();
68+
// A manager instance will only read or write snapshot files based on the
69+
// updateSnapshots argument.
70+
this.loaded = updateSnapshots;
71+
this.updateSnapshots = updateSnapshots;
72+
}
73+
74+
resolveSnapshotFile() {
75+
if (this.snapshotFile === undefined) {
76+
const resolved = resolveSnapshotPathFn(this.entryFile);
77+
78+
if (typeof resolved !== 'string') {
79+
const err = new ERR_INVALID_STATE('Invalid snapshot filename.');
80+
err.filename = resolved;
81+
throw err;
82+
}
83+
84+
this.snapshotFile = resolved;
85+
}
86+
}
87+
88+
serialize(input, serializers = serializerFns) {
89+
try {
90+
let value = input;
91+
92+
for (let i = 0; i < serializers.length; ++i) {
93+
const fn = serializers[i];
94+
value = fn(value);
95+
}
96+
97+
return `\n${templateEscape(value)}\n`;
98+
} catch (err) {
99+
const error = new ERR_INVALID_STATE(
100+
'The provided serializers did not generate a string.',
101+
);
102+
error.input = input;
103+
error.cause = err;
104+
throw error;
105+
}
106+
}
107+
108+
getSnapshot(id) {
109+
if (!(id in this.snapshots)) {
110+
const err = new ERR_INVALID_STATE(`Snapshot '${id}' not found in ` +
111+
`'${this.snapshotFile}.' ${kMissingSnapshotTip}`);
112+
err.snapshot = id;
113+
err.filename = this.snapshotFile;
114+
throw err;
115+
}
116+
117+
return this.snapshots[id];
118+
}
119+
120+
setSnapshot(id, value) {
121+
this.snapshots[templateEscape(id)] = value;
122+
}
123+
124+
nextId(name) {
125+
const count = this.nameCounts.get(name) ?? 1;
126+
127+
this.nameCounts.set(name, count + 1);
128+
return `${name} ${count}`;
129+
}
130+
131+
readSnapshotFile() {
132+
if (this.loaded) {
133+
debug('skipping read of snapshot file');
134+
return;
135+
}
136+
137+
try {
138+
const source = readFileSync(this.snapshotFile, 'utf8');
139+
const context = { __proto__: null, exports: { __proto__: null } };
140+
141+
createContext(context);
142+
runInContext(source, context);
143+
144+
if (context.exports === null || typeof context.exports !== 'object') {
145+
throw new ERR_INVALID_STATE(
146+
`Malformed snapshot file '${this.snapshotFile}'.`,
147+
);
148+
}
149+
150+
this.snapshots = context.exports;
151+
this.loaded = true;
152+
} catch (err) {
153+
let msg = `Cannot read snapshot file '${this.snapshotFile}.'`;
154+
155+
if (err?.code === 'ENOENT') {
156+
msg += ` ${kMissingSnapshotTip}`;
157+
}
158+
159+
const error = new ERR_INVALID_STATE(msg);
160+
error.cause = err;
161+
error.filename = this.snapshotFile;
162+
throw error;
163+
}
164+
}
165+
166+
writeSnapshotFile() {
167+
if (!this.updateSnapshots) {
168+
debug('skipping write of snapshot file');
169+
return;
170+
}
171+
172+
const keys = ArrayPrototypeSort(ObjectKeys(this.snapshots));
173+
const snapshotStrings = ArrayPrototypeMap(keys, (key) => {
174+
return `exports[\`${key}\`] = \`${this.snapshots[key]}\`;\n`;
175+
});
176+
const output = ArrayPrototypeJoin(snapshotStrings, '\n');
177+
mkdirSync(dirname(this.snapshotFile), { __proto__: null, recursive: true });
178+
writeFileSync(this.snapshotFile, output, 'utf8');
179+
}
180+
181+
createAssert() {
182+
const manager = this;
183+
184+
return function snapshotAssertion(actual, options = kEmptyObject) {
185+
emitExperimentalWarning(kExperimentalWarning);
186+
// Resolve the snapshot file here so that any resolution errors are
187+
// surfaced as early as possible.
188+
manager.resolveSnapshotFile();
189+
190+
const { fullName } = this;
191+
const id = manager.nextId(fullName);
192+
193+
validateObject(options, 'options');
194+
195+
const {
196+
serializers = serializerFns,
197+
} = options;
198+
199+
validateFunctionArray(serializers, 'options.serializers');
200+
201+
const value = manager.serialize(actual, serializers);
202+
203+
if (manager.updateSnapshots) {
204+
manager.setSnapshot(id, value);
205+
} else {
206+
manager.readSnapshotFile();
207+
strictEqual(value, manager.getSnapshot(id));
208+
}
209+
};
210+
}
211+
}
212+
213+
function validateFunctionArray(fns, name) {
214+
validateArray(fns, name);
215+
for (let i = 0; i < fns.length; ++i) {
216+
validateFunction(fns[i], `${name}[${i}]`);
217+
}
218+
}
219+
220+
function templateEscape(str) {
221+
let result = String(str);
222+
result = StringPrototypeReplaceAll(result, '\\', '\\\\');
223+
result = StringPrototypeReplaceAll(result, '`', '\\`');
224+
result = StringPrototypeReplaceAll(result, '${', '\\${');
225+
return result;
226+
}
227+
228+
module.exports = {
229+
SnapshotManager,
230+
defaultResolveSnapshotPath, // Exported for testing only.
231+
defaultSerializers, // Exported for testing only.
232+
setDefaultSerializers,
233+
setResolveSnapshotPath,
234+
};

lib/internal/test_runner/test.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const {
8686
testNamePatterns,
8787
testSkipPatterns,
8888
testOnlyFlag,
89+
updateSnapshots,
8990
} = parseCommandLine();
9091
let kResistStopPropagation;
9192
let assertObj;
@@ -102,11 +103,10 @@ function lazyFindSourceMap(file) {
102103
return findSourceMap(file);
103104
}
104105

105-
function lazyAssertObject() {
106+
function lazyAssertObject(harness) {
106107
if (assertObj === undefined) {
107108
assertObj = new SafeMap();
108109
const assert = require('assert');
109-
110110
const methodsToCopy = [
111111
'deepEqual',
112112
'deepStrictEqual',
@@ -129,6 +129,13 @@ function lazyAssertObject() {
129129
for (let i = 0; i < methodsToCopy.length; i++) {
130130
assertObj.set(methodsToCopy[i], assert[methodsToCopy[i]]);
131131
}
132+
133+
const { getOptionValue } = require('internal/options');
134+
if (getOptionValue('--experimental-test-snapshots')) {
135+
const { SnapshotManager } = require('internal/test_runner/snapshot');
136+
harness.snapshotManager = new SnapshotManager(kFilename, updateSnapshots);
137+
assertObj.set('snapshot', harness.snapshotManager.createAssert());
138+
}
132139
}
133140
return assertObj;
134141
}
@@ -248,7 +255,7 @@ class TestContext {
248255
get assert() {
249256
if (this.#assert === undefined) {
250257
const { plan } = this.#test;
251-
const map = lazyAssertObject();
258+
const map = lazyAssertObject(this.#test.root.harness);
252259
const assert = { __proto__: null };
253260

254261
this.#assert = assert;
@@ -257,7 +264,7 @@ class TestContext {
257264
if (plan !== null) {
258265
plan.actual++;
259266
}
260-
return ReflectApply(method, assert, args);
267+
return ReflectApply(method, this, args);
261268
};
262269
});
263270
}
@@ -960,6 +967,7 @@ class Test extends AsyncResource {
960967

961968
// Call this harness.coverage() before collecting diagnostics, since failure to collect coverage is a diagnostic.
962969
const coverage = harness.coverage();
970+
harness.snapshotManager?.writeSnapshotFile();
963971
for (let i = 0; i < diagnostics.length; i++) {
964972
reporter.diagnostic(nesting, loc, diagnostics[i]);
965973
}
@@ -980,6 +988,7 @@ class Test extends AsyncResource {
980988
if (harness.watching) {
981989
this.reported = false;
982990
harness.resetCounters();
991+
assertObj = undefined;
983992
} else {
984993
reporter.end();
985994
}

lib/internal/test_runner/utils.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ function parseCommandLine() {
195195
const coverage = getOptionValue('--experimental-test-coverage');
196196
const forceExit = getOptionValue('--test-force-exit');
197197
const sourceMaps = getOptionValue('--enable-source-maps');
198+
const updateSnapshots = getOptionValue('--test-update-snapshots');
198199
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
199200
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
200201
let destinations;
@@ -255,6 +256,7 @@ function parseCommandLine() {
255256
testOnlyFlag,
256257
testNamePatterns,
257258
testSkipPatterns,
259+
updateSnapshots,
258260
reporters,
259261
destinations,
260262
};

lib/test.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const {
77

88
const { test, suite, before, after, beforeEach, afterEach } = require('internal/test_runner/harness');
99
const { run } = require('internal/test_runner/runner');
10+
const { getOptionValue } = require('internal/options');
1011

1112
module.exports = test;
1213
ObjectAssign(module.exports, {
@@ -37,3 +38,29 @@ ObjectDefineProperty(module.exports, 'mock', {
3738
return lazyMock;
3839
},
3940
});
41+
42+
if (getOptionValue('--experimental-test-snapshots')) {
43+
let lazySnapshot;
44+
45+
ObjectDefineProperty(module.exports, 'snapshot', {
46+
__proto__: null,
47+
configurable: true,
48+
enumerable: true,
49+
get() {
50+
if (lazySnapshot === undefined) {
51+
const {
52+
setDefaultSerializers,
53+
setResolveSnapshotPath,
54+
} = require('internal/test_runner/snapshot');
55+
56+
lazySnapshot = {
57+
__proto__: null,
58+
setDefaultSerializers,
59+
setResolveSnapshotPath,
60+
};
61+
}
62+
63+
return lazySnapshot;
64+
},
65+
});
66+
}

src/node_options.cc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,12 +623,18 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
623623
AddOption("--test-timeout",
624624
"specify test runner timeout",
625625
&EnvironmentOptions::test_runner_timeout);
626+
AddOption("--test-update-snapshots",
627+
"regenerate test snapshots",
628+
&EnvironmentOptions::test_runner_update_snapshots);
626629
AddOption("--experimental-test-coverage",
627630
"enable code coverage in the test runner",
628631
&EnvironmentOptions::test_runner_coverage);
629632
AddOption("--experimental-test-module-mocks",
630633
"enable module mocking in the test runner",
631634
&EnvironmentOptions::test_runner_module_mocks);
635+
AddOption("--experimental-test-snapshots",
636+
"enable snapshot testing in the test runner",
637+
&EnvironmentOptions::test_runner_snapshots);
632638
AddOption("--test-name-pattern",
633639
"run tests whose name matches this regular expression",
634640
&EnvironmentOptions::test_name_pattern);

src/node_options.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ class EnvironmentOptions : public Options {
174174
bool test_runner_coverage = false;
175175
bool test_runner_force_exit = false;
176176
bool test_runner_module_mocks = false;
177+
bool test_runner_snapshots = false;
178+
bool test_runner_update_snapshots = false;
177179
std::vector<std::string> test_name_pattern;
178180
std::vector<std::string> test_reporter;
179181
std::vector<std::string> test_reporter_destination;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
exports = null;

0 commit comments

Comments
 (0)