mirror of https://github.com/nodejs/node.git
test_runner: add snapshot testing
This commit adds a t.assert.snapshot() method that implements snapshot testing. Serialization uses JSON.stringify() by default, but users can configure the serialization to meet their needs. PR-URL: https://github.com/nodejs/node/pull/53169 Fixes: https://github.com/nodejs/node/issues/48260 Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com> Reviewed-By: Geoffrey Booth <webadmin@geoffreybooth.com>
This commit is contained in:
parent
851dcddb57
commit
9f6c12413c
|
@ -991,6 +991,16 @@ added: REPLACEME
|
|||
|
||||
Enable module mocking in the test runner.
|
||||
|
||||
### `--experimental-test-snapshots`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
> Stability: 1.0 - Early development
|
||||
|
||||
Enable [snapshot testing][] in the test runner.
|
||||
|
||||
### `--experimental-vm-modules`
|
||||
|
||||
<!-- YAML
|
||||
|
@ -2129,6 +2139,18 @@ added:
|
|||
A number of milliseconds the test execution will fail after. If unspecified,
|
||||
subtests inherit this value from their parent. The default value is `Infinity`.
|
||||
|
||||
### `--test-update-snapshots`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
> Stability: 1.0 - Early development
|
||||
|
||||
Regenerates the snapshot file used by the test runner for [snapshot testing][].
|
||||
Node.js must be started with the `--experimental-test-snapshots` flag in order
|
||||
to use this functionality.
|
||||
|
||||
### `--throw-deprecation`
|
||||
|
||||
<!-- YAML
|
||||
|
@ -3269,6 +3291,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
|
|||
[security warning]: #warning-binding-inspector-to-a-public-ipport-combination-is-insecure
|
||||
[semi-space]: https://www.memorymanagement.org/glossary/s.html#semi.space
|
||||
[single executable application]: single-executable-applications.md
|
||||
[snapshot testing]: test.md#snapshot-testing
|
||||
[test reporters]: test.md#test-reporters
|
||||
[timezone IDs]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
[tracking issue for user-land snapshots]: https://github.com/nodejs/node/issues/44014
|
||||
|
|
140
doc/api/test.md
140
doc/api/test.md
|
@ -920,6 +920,61 @@ test('runs timers as setTime passes ticks', (context) => {
|
|||
});
|
||||
```
|
||||
|
||||
## Snapshot testing
|
||||
|
||||
> Stability: 1.0 - Early development
|
||||
|
||||
Snapshot tests allow arbitrary values to be serialized into string values and
|
||||
compared against a set of known good values. The known good values are known as
|
||||
snapshots, and are stored in a snapshot file. Snapshot files are managed by the
|
||||
test runner, but are designed to be human readable to aid in debugging. Best
|
||||
practice is for snapshot files to be checked into source control along with your
|
||||
test files. In order to enable snapshot testing, Node.js must be started with
|
||||
the [`--experimental-test-snapshots`][] command-line flag.
|
||||
|
||||
Snapshot files are generated by starting Node.js with the
|
||||
[`--test-update-snapshots`][] command-line flag. A separate snapshot file is
|
||||
generated for each test file. By default, the snapshot file has the same name
|
||||
as `process.argv[1]` with a `.snapshot` file extension. This behavior can be
|
||||
configured using the `snapshot.setResolveSnapshotPath()` function. Each
|
||||
snapshot assertion corresponds to an export in the snapshot file.
|
||||
|
||||
An example snapshot test is shown below. The first time this test is executed,
|
||||
it will fail because the corresponding snapshot file does not exist.
|
||||
|
||||
```js
|
||||
// test.js
|
||||
suite('suite of snapshot tests', () => {
|
||||
test('snapshot test', (t) => {
|
||||
t.assert.snapshot({ value1: 1, value2: 2 });
|
||||
t.assert.snapshot(5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Generate the snapshot file by running the test file with
|
||||
`--test-update-snapshots`. The test should pass, and a file named
|
||||
`test.js.snapshot` is created in the same directory as the test file. The
|
||||
contents of the snapshot file are shown below. Each snapshot is identified by
|
||||
the full name of test and a counter to differentiate between snapshots in the
|
||||
same test.
|
||||
|
||||
```js
|
||||
exports[`suite of snapshot tests > snapshot test 1`] = `
|
||||
{
|
||||
"value1": 1,
|
||||
"value2": 2
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`suite of snapshot tests > snapshot test 2`] = `
|
||||
5
|
||||
`;
|
||||
```
|
||||
|
||||
Once the snapshot file is created, run the tests again without the
|
||||
`--test-update-snapshots` flag. The tests should pass now.
|
||||
|
||||
## Test reporters
|
||||
|
||||
<!-- YAML
|
||||
|
@ -1622,6 +1677,54 @@ describe('tests', async () => {
|
|||
});
|
||||
```
|
||||
|
||||
## `snapshot`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
> Stability: 1.0 - Early development
|
||||
|
||||
An object whose methods are used to configure default snapshot settings in the
|
||||
current process. It is possible to apply the same configuration to all files by
|
||||
placing common configuration code in a module preloaded with `--require` or
|
||||
`--import`.
|
||||
|
||||
### `snapshot.setDefaultSnapshotSerializers(serializers)`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
> Stability: 1.0 - Early development
|
||||
|
||||
* `serializers` {Array} An array of synchronous functions used as the default
|
||||
serializers for snapshot tests.
|
||||
|
||||
This function is used to customize the default serialization mechanism used by
|
||||
the test runner. By default, the test runner performs serialization by calling
|
||||
`JSON.stringify(value, null, 2)` on the provided value. `JSON.stringify()` does
|
||||
have limitations regarding circular structures and supported data types. If a
|
||||
more robust serialization mechanism is required, this function should be used.
|
||||
|
||||
### `snapshot.setResolveSnapshotPath(fn)`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
> Stability: 1.0 - Early development
|
||||
|
||||
* `fn` {Function} A function used to compute the location of the snapshot file.
|
||||
The function receives the path of the test file as its only argument. If the
|
||||
`process.argv[1]` is not associated with a file (for example in the REPL),
|
||||
the input is undefined. `fn()` must return a string specifying the location of
|
||||
the snapshot file.
|
||||
|
||||
This function is used to customize the location of the snapshot file used for
|
||||
snapshot testing. By default, the snapshot filename is the same as the entry
|
||||
point filename with a `.snapshot` file extension.
|
||||
|
||||
## Class: `MockFunctionContext`
|
||||
|
||||
<!-- YAML
|
||||
|
@ -3042,6 +3145,41 @@ test('test', (t) => {
|
|||
});
|
||||
```
|
||||
|
||||
#### `context.assert.snapshot(value[, options])`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
> Stability: 1.0 - Early development
|
||||
|
||||
* `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
|
||||
the snapshot file. Otherwise, the serialized value is compared to the
|
||||
corresponding value in the existing snapshot file.
|
||||
* `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 implements assertions for snapshot testing.
|
||||
|
||||
```js
|
||||
test('snapshot test with default serialization', (t) => {
|
||||
t.assert.snapshot({ value1: 1, value2: 2 });
|
||||
});
|
||||
|
||||
test('snapshot test with custom serialization', (t) => {
|
||||
t.assert.snapshot({ value3: 3, value4: 4 }, {
|
||||
serializers: [(value) => JSON.stringify(value)]
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### `context.diagnostic(message)`
|
||||
|
||||
<!-- YAML
|
||||
|
@ -3320,6 +3458,7 @@ Can be used to abort test subtasks when the test has been aborted.
|
|||
[TAP]: https://testanything.org/
|
||||
[TTY]: tty.md
|
||||
[`--experimental-test-coverage`]: cli.md#--experimental-test-coverage
|
||||
[`--experimental-test-snapshots`]: cli.md#--experimental-test-snapshots
|
||||
[`--import`]: cli.md#--importmodule
|
||||
[`--test-concurrency`]: cli.md#--test-concurrency
|
||||
[`--test-name-pattern`]: cli.md#--test-name-pattern
|
||||
|
@ -3327,6 +3466,7 @@ Can be used to abort test subtasks when the test has been aborted.
|
|||
[`--test-reporter-destination`]: cli.md#--test-reporter-destination
|
||||
[`--test-reporter`]: cli.md#--test-reporter
|
||||
[`--test-skip-pattern`]: cli.md#--test-skip-pattern
|
||||
[`--test-update-snapshots`]: cli.md#--test-update-snapshots
|
||||
[`--test`]: cli.md#--test
|
||||
[`MockFunctionContext`]: #class-mockfunctioncontext
|
||||
[`MockTimers`]: #class-mocktimers
|
||||
|
|
|
@ -188,6 +188,9 @@ Enable code coverage in the test runner.
|
|||
.It Fl -experimental-test-module-mocks
|
||||
Enable module mocking in the test runner.
|
||||
.
|
||||
.It Fl -experimental-test-snapshots
|
||||
Enable snapshot testing in the test runner.
|
||||
.
|
||||
.It Fl -experimental-eventsource
|
||||
Enable experimental support for the EventSource Web API.
|
||||
.
|
||||
|
@ -451,6 +454,9 @@ whose name matches the provided pattern.
|
|||
.It Fl -test-timeout
|
||||
A number of milliseconds the test execution will fail after.
|
||||
.
|
||||
.It Fl -test-update-snapshots
|
||||
Regenerates the snapshot file used by the test runner for snapshot testing.
|
||||
.
|
||||
.It Fl -throw-deprecation
|
||||
Throw errors for deprecations.
|
||||
.
|
||||
|
|
|
@ -210,6 +210,7 @@ function setup(root) {
|
|||
counters: null,
|
||||
shouldColorizeTestFiles: false,
|
||||
teardown: exitHandler,
|
||||
snapshotManager: null,
|
||||
};
|
||||
root.harness.resetCounters();
|
||||
root.startTime = hrtime();
|
||||
|
|
|
@ -0,0 +1,241 @@
|
|||
'use strict';
|
||||
const {
|
||||
ArrayPrototypeJoin,
|
||||
ArrayPrototypeMap,
|
||||
ArrayPrototypeSlice,
|
||||
ArrayPrototypeSort,
|
||||
JSONStringify,
|
||||
ObjectKeys,
|
||||
SafeMap,
|
||||
String,
|
||||
StringPrototypeReplaceAll,
|
||||
} = primordials;
|
||||
const {
|
||||
codes: {
|
||||
ERR_INVALID_STATE,
|
||||
},
|
||||
} = require('internal/errors');
|
||||
const { emitExperimentalWarning, kEmptyObject } = require('internal/util');
|
||||
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
|
||||
debug = fn;
|
||||
});
|
||||
const {
|
||||
validateArray,
|
||||
validateFunction,
|
||||
validateObject,
|
||||
} = require('internal/validators');
|
||||
const { strictEqual } = require('assert');
|
||||
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
|
||||
const { dirname } = require('path');
|
||||
const { createContext, runInContext } = require('vm');
|
||||
const kExperimentalWarning = 'Snapshot testing';
|
||||
const kMissingSnapshotTip = 'Missing snapshots can be generated by rerunning ' +
|
||||
'the command with the --test-update-snapshots flag.';
|
||||
const defaultSerializers = [
|
||||
(value) => { return JSONStringify(value, null, 2); },
|
||||
];
|
||||
|
||||
function defaultResolveSnapshotPath(testPath) {
|
||||
if (typeof testPath !== 'string') {
|
||||
return testPath;
|
||||
}
|
||||
|
||||
return `${testPath}.snapshot`;
|
||||
}
|
||||
|
||||
let resolveSnapshotPathFn = defaultResolveSnapshotPath;
|
||||
let serializerFns = defaultSerializers;
|
||||
|
||||
function setResolveSnapshotPath(fn) {
|
||||
emitExperimentalWarning(kExperimentalWarning);
|
||||
validateFunction(fn, 'fn');
|
||||
resolveSnapshotPathFn = fn;
|
||||
}
|
||||
|
||||
function setDefaultSnapshotSerializers(serializers) {
|
||||
emitExperimentalWarning(kExperimentalWarning);
|
||||
validateFunctionArray(serializers, 'serializers');
|
||||
serializerFns = ArrayPrototypeSlice(serializers);
|
||||
}
|
||||
|
||||
class SnapshotManager {
|
||||
constructor(entryFile, updateSnapshots) {
|
||||
this.entryFile = entryFile;
|
||||
this.snapshotFile = undefined;
|
||||
this.snapshots = { __proto__: null };
|
||||
this.nameCounts = new SafeMap();
|
||||
// A manager instance will only read or write snapshot files based on the
|
||||
// updateSnapshots argument.
|
||||
this.loaded = updateSnapshots;
|
||||
this.updateSnapshots = updateSnapshots;
|
||||
}
|
||||
|
||||
resolveSnapshotFile() {
|
||||
if (this.snapshotFile === undefined) {
|
||||
const resolved = resolveSnapshotPathFn(this.entryFile);
|
||||
|
||||
if (typeof resolved !== 'string') {
|
||||
const err = new ERR_INVALID_STATE('Invalid snapshot filename.');
|
||||
err.filename = resolved;
|
||||
throw err;
|
||||
}
|
||||
|
||||
this.snapshotFile = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
serialize(input, serializers = serializerFns) {
|
||||
try {
|
||||
let value = input;
|
||||
|
||||
for (let i = 0; i < serializers.length; ++i) {
|
||||
const fn = serializers[i];
|
||||
value = fn(value);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot(id) {
|
||||
if (!(id in this.snapshots)) {
|
||||
const err = new ERR_INVALID_STATE(`Snapshot '${id}' not found in ` +
|
||||
`'${this.snapshotFile}.' ${kMissingSnapshotTip}`);
|
||||
err.snapshot = id;
|
||||
err.filename = this.snapshotFile;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return this.snapshots[id];
|
||||
}
|
||||
|
||||
setSnapshot(id, value) {
|
||||
this.snapshots[templateEscape(id)] = value;
|
||||
}
|
||||
|
||||
nextId(name) {
|
||||
const count = this.nameCounts.get(name) ?? 1;
|
||||
|
||||
this.nameCounts.set(name, count + 1);
|
||||
return `${name} ${count}`;
|
||||
}
|
||||
|
||||
readSnapshotFile() {
|
||||
if (this.loaded) {
|
||||
debug('skipping read of snapshot file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = readFileSync(this.snapshotFile, 'utf8');
|
||||
const context = { __proto__: null, exports: { __proto__: null } };
|
||||
|
||||
createContext(context);
|
||||
runInContext(source, context);
|
||||
|
||||
if (context.exports === null || typeof context.exports !== 'object') {
|
||||
throw new ERR_INVALID_STATE(
|
||||
`Malformed snapshot file '${this.snapshotFile}'.`,
|
||||
);
|
||||
}
|
||||
|
||||
this.snapshots = context.exports;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
writeSnapshotFile() {
|
||||
if (!this.updateSnapshots) {
|
||||
debug('skipping write of snapshot file');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keys = ArrayPrototypeSort(ObjectKeys(this.snapshots));
|
||||
const snapshotStrings = ArrayPrototypeMap(keys, (key) => {
|
||||
return `exports[\`${key}\`] = \`${this.snapshots[key]}\`;\n`;
|
||||
});
|
||||
const output = ArrayPrototypeJoin(snapshotStrings, '\n');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
createAssert() {
|
||||
const manager = this;
|
||||
|
||||
return function snapshotAssertion(actual, options = kEmptyObject) {
|
||||
emitExperimentalWarning(kExperimentalWarning);
|
||||
// Resolve the snapshot file here so that any resolution errors are
|
||||
// surfaced as early as possible.
|
||||
manager.resolveSnapshotFile();
|
||||
|
||||
const { fullName } = this;
|
||||
const id = manager.nextId(fullName);
|
||||
|
||||
validateObject(options, 'options');
|
||||
|
||||
const {
|
||||
serializers = serializerFns,
|
||||
} = options;
|
||||
|
||||
validateFunctionArray(serializers, 'options.serializers');
|
||||
|
||||
const value = manager.serialize(actual, serializers);
|
||||
|
||||
if (manager.updateSnapshots) {
|
||||
manager.setSnapshot(id, value);
|
||||
} else {
|
||||
manager.readSnapshotFile();
|
||||
strictEqual(value, manager.getSnapshot(id));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateFunctionArray(fns, name) {
|
||||
validateArray(fns, name);
|
||||
for (let i = 0; i < fns.length; ++i) {
|
||||
validateFunction(fns[i], `${name}[${i}]`);
|
||||
}
|
||||
}
|
||||
|
||||
function templateEscape(str) {
|
||||
let result = String(str);
|
||||
result = StringPrototypeReplaceAll(result, '\\', '\\\\');
|
||||
result = StringPrototypeReplaceAll(result, '`', '\\`');
|
||||
result = StringPrototypeReplaceAll(result, '${', '\\${');
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SnapshotManager,
|
||||
defaultResolveSnapshotPath, // Exported for testing only.
|
||||
defaultSerializers, // Exported for testing only.
|
||||
setDefaultSnapshotSerializers,
|
||||
setResolveSnapshotPath,
|
||||
};
|
|
@ -86,6 +86,7 @@ const {
|
|||
testNamePatterns,
|
||||
testSkipPatterns,
|
||||
testOnlyFlag,
|
||||
updateSnapshots,
|
||||
} = parseCommandLine();
|
||||
let kResistStopPropagation;
|
||||
let assertObj;
|
||||
|
@ -102,11 +103,10 @@ function lazyFindSourceMap(file) {
|
|||
return findSourceMap(file);
|
||||
}
|
||||
|
||||
function lazyAssertObject() {
|
||||
function lazyAssertObject(harness) {
|
||||
if (assertObj === undefined) {
|
||||
assertObj = new SafeMap();
|
||||
const assert = require('assert');
|
||||
|
||||
const methodsToCopy = [
|
||||
'deepEqual',
|
||||
'deepStrictEqual',
|
||||
|
@ -129,6 +129,13 @@ function lazyAssertObject() {
|
|||
for (let i = 0; i < methodsToCopy.length; i++) {
|
||||
assertObj.set(methodsToCopy[i], assert[methodsToCopy[i]]);
|
||||
}
|
||||
|
||||
const { getOptionValue } = require('internal/options');
|
||||
if (getOptionValue('--experimental-test-snapshots')) {
|
||||
const { SnapshotManager } = require('internal/test_runner/snapshot');
|
||||
harness.snapshotManager = new SnapshotManager(kFilename, updateSnapshots);
|
||||
assertObj.set('snapshot', harness.snapshotManager.createAssert());
|
||||
}
|
||||
}
|
||||
return assertObj;
|
||||
}
|
||||
|
@ -248,7 +255,7 @@ class TestContext {
|
|||
get assert() {
|
||||
if (this.#assert === undefined) {
|
||||
const { plan } = this.#test;
|
||||
const map = lazyAssertObject();
|
||||
const map = lazyAssertObject(this.#test.root.harness);
|
||||
const assert = { __proto__: null };
|
||||
|
||||
this.#assert = assert;
|
||||
|
@ -257,7 +264,7 @@ class TestContext {
|
|||
if (plan !== null) {
|
||||
plan.actual++;
|
||||
}
|
||||
return ReflectApply(method, assert, args);
|
||||
return ReflectApply(method, this, args);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -960,6 +967,7 @@ class Test extends AsyncResource {
|
|||
|
||||
// Call this harness.coverage() before collecting diagnostics, since failure to collect coverage is a diagnostic.
|
||||
const coverage = harness.coverage();
|
||||
harness.snapshotManager?.writeSnapshotFile();
|
||||
for (let i = 0; i < diagnostics.length; i++) {
|
||||
reporter.diagnostic(nesting, loc, diagnostics[i]);
|
||||
}
|
||||
|
@ -980,6 +988,7 @@ class Test extends AsyncResource {
|
|||
if (harness.watching) {
|
||||
this.reported = false;
|
||||
harness.resetCounters();
|
||||
assertObj = undefined;
|
||||
} else {
|
||||
reporter.end();
|
||||
}
|
||||
|
|
|
@ -195,6 +195,7 @@ function parseCommandLine() {
|
|||
const coverage = getOptionValue('--experimental-test-coverage');
|
||||
const forceExit = getOptionValue('--test-force-exit');
|
||||
const sourceMaps = getOptionValue('--enable-source-maps');
|
||||
const updateSnapshots = getOptionValue('--test-update-snapshots');
|
||||
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
|
||||
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
|
||||
let destinations;
|
||||
|
@ -255,6 +256,7 @@ function parseCommandLine() {
|
|||
testOnlyFlag,
|
||||
testNamePatterns,
|
||||
testSkipPatterns,
|
||||
updateSnapshots,
|
||||
reporters,
|
||||
destinations,
|
||||
};
|
||||
|
|
27
lib/test.js
27
lib/test.js
|
@ -7,6 +7,7 @@ const {
|
|||
|
||||
const { test, suite, before, after, beforeEach, afterEach } = require('internal/test_runner/harness');
|
||||
const { run } = require('internal/test_runner/runner');
|
||||
const { getOptionValue } = require('internal/options');
|
||||
|
||||
module.exports = test;
|
||||
ObjectAssign(module.exports, {
|
||||
|
@ -37,3 +38,29 @@ ObjectDefineProperty(module.exports, 'mock', {
|
|||
return lazyMock;
|
||||
},
|
||||
});
|
||||
|
||||
if (getOptionValue('--experimental-test-snapshots')) {
|
||||
let lazySnapshot;
|
||||
|
||||
ObjectDefineProperty(module.exports, 'snapshot', {
|
||||
__proto__: null,
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
if (lazySnapshot === undefined) {
|
||||
const {
|
||||
setDefaultSnapshotSerializers,
|
||||
setResolveSnapshotPath,
|
||||
} = require('internal/test_runner/snapshot');
|
||||
|
||||
lazySnapshot = {
|
||||
__proto__: null,
|
||||
setDefaultSnapshotSerializers,
|
||||
setResolveSnapshotPath,
|
||||
};
|
||||
}
|
||||
|
||||
return lazySnapshot;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -623,12 +623,18 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
|
|||
AddOption("--test-timeout",
|
||||
"specify test runner timeout",
|
||||
&EnvironmentOptions::test_runner_timeout);
|
||||
AddOption("--test-update-snapshots",
|
||||
"regenerate test snapshots",
|
||||
&EnvironmentOptions::test_runner_update_snapshots);
|
||||
AddOption("--experimental-test-coverage",
|
||||
"enable code coverage in the test runner",
|
||||
&EnvironmentOptions::test_runner_coverage);
|
||||
AddOption("--experimental-test-module-mocks",
|
||||
"enable module mocking in the test runner",
|
||||
&EnvironmentOptions::test_runner_module_mocks);
|
||||
AddOption("--experimental-test-snapshots",
|
||||
"enable snapshot testing in the test runner",
|
||||
&EnvironmentOptions::test_runner_snapshots);
|
||||
AddOption("--test-name-pattern",
|
||||
"run tests whose name matches this regular expression",
|
||||
&EnvironmentOptions::test_name_pattern);
|
||||
|
|
|
@ -174,6 +174,8 @@ class EnvironmentOptions : public Options {
|
|||
bool test_runner_coverage = false;
|
||||
bool test_runner_force_exit = false;
|
||||
bool test_runner_module_mocks = false;
|
||||
bool test_runner_snapshots = false;
|
||||
bool test_runner_update_snapshots = false;
|
||||
std::vector<std::string> test_name_pattern;
|
||||
std::vector<std::string> test_reporter;
|
||||
std::vector<std::string> test_reporter_destination;
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
exports = null;
|
|
@ -0,0 +1,6 @@
|
|||
exports[`foo 1`] = `
|
||||
{
|
||||
"bar": 1,
|
||||
"baz": 2
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,24 @@
|
|||
'use strict';
|
||||
const { snapshot, suite, test } = require('node:test');
|
||||
const { basename, join } = require('node:path');
|
||||
|
||||
snapshot.setResolveSnapshotPath((testFile) => {
|
||||
return join(process.cwd(), `${basename(testFile)}.snapshot`);
|
||||
});
|
||||
|
||||
suite('suite', () => {
|
||||
test('test with plan', (t) => {
|
||||
t.plan(2);
|
||||
t.assert.snapshot({ foo: 1, bar: 2 });
|
||||
t.assert.snapshot(5);
|
||||
});
|
||||
});
|
||||
|
||||
test('test', async (t) => {
|
||||
t.assert.snapshot({ baz: 9 });
|
||||
});
|
||||
|
||||
test('`${foo}`', async (t) => {
|
||||
const options = { serializers: [() => { return '***'; }]};
|
||||
t.assert.snapshot('snapshotted string', options);
|
||||
});
|
|
@ -0,0 +1,332 @@
|
|||
// Flags: --expose-internals --experimental-test-snapshots
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
'use strict';
|
||||
const common = require('../common');
|
||||
const fixtures = require('../common/fixtures');
|
||||
const tmpdir = require('../common/tmpdir');
|
||||
const {
|
||||
snapshot,
|
||||
suite,
|
||||
test,
|
||||
} = require('node:test');
|
||||
const {
|
||||
SnapshotManager,
|
||||
defaultResolveSnapshotPath,
|
||||
defaultSerializers,
|
||||
} = require('internal/test_runner/snapshot');
|
||||
const fs = require('node:fs');
|
||||
|
||||
tmpdir.refresh();
|
||||
|
||||
suite('SnapshotManager', () => {
|
||||
test('uses default snapshot naming scheme', (t) => {
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
manager.resolveSnapshotFile();
|
||||
t.assert.strictEqual(manager.snapshotFile, `${__filename}.snapshot`);
|
||||
});
|
||||
|
||||
test('generates snapshot IDs based on provided name', (t) => {
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
|
||||
t.assert.strictEqual(manager.nextId('foo'), 'foo 1');
|
||||
t.assert.strictEqual(manager.nextId('foo'), 'foo 2');
|
||||
t.assert.strictEqual(manager.nextId('bar'), 'bar 1');
|
||||
t.assert.strictEqual(manager.nextId('baz'), 'baz 1');
|
||||
t.assert.strictEqual(manager.nextId('foo'), 'foo 3');
|
||||
t.assert.strictEqual(manager.nextId('foo`'), 'foo` 1');
|
||||
t.assert.strictEqual(manager.nextId('foo\\'), 'foo\\ 1');
|
||||
t.assert.strictEqual(manager.nextId('foo`${x}`'), 'foo`${x}` 1');
|
||||
});
|
||||
|
||||
test('throws if snapshot file does not have exports', (t) => {
|
||||
const fixture = fixtures.path(
|
||||
'test-runner', 'snapshots', 'malformed-exports.js'
|
||||
);
|
||||
const manager = new SnapshotManager(fixture, false);
|
||||
|
||||
t.assert.throws(() => {
|
||||
manager.resolveSnapshotFile();
|
||||
manager.readSnapshotFile();
|
||||
}, (err) => {
|
||||
t.assert.strictEqual(err.code, 'ERR_INVALID_STATE');
|
||||
t.assert.match(err.message, /Cannot read snapshot/);
|
||||
t.assert.strictEqual(err.filename, manager.snapshotFile);
|
||||
t.assert.match(err.cause.message, /Malformed snapshot file/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('provides a tip if snapshot file does not exist', (t) => {
|
||||
const fixture = fixtures.path(
|
||||
'test-runner', 'snapshots', 'this-file-should-not-exist.js'
|
||||
);
|
||||
const manager = new SnapshotManager(fixture, false);
|
||||
|
||||
t.assert.throws(() => {
|
||||
manager.resolveSnapshotFile();
|
||||
manager.readSnapshotFile();
|
||||
}, /Missing snapshots can be generated by rerunning the command/);
|
||||
});
|
||||
|
||||
test('throws if serialization cannot generate a string', (t) => {
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
const cause = new Error('boom');
|
||||
const input = {
|
||||
foo: 1,
|
||||
toString() {
|
||||
throw cause;
|
||||
},
|
||||
};
|
||||
|
||||
t.assert.throws(() => {
|
||||
manager.serialize(input, [(value) => { return value; }]);
|
||||
}, (err) => {
|
||||
t.assert.strictEqual(err.code, 'ERR_INVALID_STATE');
|
||||
t.assert.match(err.message, /The provided serializers did not generate a string/);
|
||||
t.assert.strictEqual(err.input, input);
|
||||
t.assert.strictEqual(err.cause, cause);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('serializes values using provided functions', (t) => {
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
const output = manager.serialize({ foo: 1 }, [
|
||||
(value) => { return JSON.stringify(value); },
|
||||
(value) => { return value + '424242'; },
|
||||
]);
|
||||
|
||||
t.assert.strictEqual(output, '\n{"foo":1}424242\n');
|
||||
});
|
||||
|
||||
test('serialized values get cast to string', (t) => {
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
const output = manager.serialize(5, []);
|
||||
|
||||
t.assert.strictEqual(output, '\n5\n');
|
||||
});
|
||||
|
||||
test('serialized values get escaped', (t) => {
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
const output = manager.serialize('fo\\o`${x}`', []);
|
||||
|
||||
t.assert.strictEqual(output, '\nfo\\\\o\\`\\${x}\\`\n');
|
||||
});
|
||||
|
||||
test('reads individual snapshots from snapshot file', (t) => {
|
||||
const fixture = fixtures.path('test-runner', 'snapshots', 'simple.js');
|
||||
const manager = new SnapshotManager(fixture, false);
|
||||
manager.resolveSnapshotFile();
|
||||
manager.readSnapshotFile();
|
||||
const snapshot = manager.getSnapshot('foo 1');
|
||||
|
||||
t.assert.strictEqual(snapshot, '\n{\n "bar": 1,\n "baz": 2\n}\n');
|
||||
});
|
||||
|
||||
test('snapshot file is not read in update mode', (t) => {
|
||||
const fixture = fixtures.path('test-runner', 'snapshots', 'simple.js');
|
||||
const manager = new SnapshotManager(fixture, true);
|
||||
manager.readSnapshotFile();
|
||||
|
||||
t.assert.throws(() => {
|
||||
manager.getSnapshot('foo 1');
|
||||
}, /Snapshot 'foo 1' not found/);
|
||||
});
|
||||
|
||||
test('throws if requested snapshot does not exist in file', (t) => {
|
||||
const fixture = fixtures.path('test-runner', 'snapshots', 'simple.js');
|
||||
const manager = new SnapshotManager(fixture, false);
|
||||
|
||||
t.assert.throws(() => {
|
||||
manager.getSnapshot('does not exist 1');
|
||||
}, (err) => {
|
||||
t.assert.strictEqual(err.code, 'ERR_INVALID_STATE');
|
||||
t.assert.match(err.message, /Snapshot 'does not exist 1' not found/);
|
||||
t.assert.strictEqual(err.snapshot, 'does not exist 1');
|
||||
t.assert.strictEqual(err.filename, manager.snapshotFile);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('snapshot IDs are escaped when stored', (t) => {
|
||||
const fixture = fixtures.path('test-runner', 'snapshots', 'simple.js');
|
||||
const manager = new SnapshotManager(fixture, false);
|
||||
|
||||
manager.setSnapshot('foo`${x}` 1', 'test');
|
||||
t.assert.strictEqual(manager.getSnapshot('foo\\`\\${x}\\` 1'), 'test');
|
||||
});
|
||||
|
||||
test('throws if snapshot file cannot be resolved', (t) => {
|
||||
const manager = new SnapshotManager(null, false);
|
||||
const assertion = manager.createAssert();
|
||||
|
||||
t.assert.throws(() => {
|
||||
assertion('foo');
|
||||
}, (err) => {
|
||||
t.assert.strictEqual(err.code, 'ERR_INVALID_STATE');
|
||||
t.assert.match(err.message, /Invalid snapshot filename/);
|
||||
t.assert.strictEqual(err.filename, null);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('writes the specified snapshot file', (t) => {
|
||||
const testFile = tmpdir.resolve('test1.js');
|
||||
const manager = new SnapshotManager(testFile, true);
|
||||
manager.resolveSnapshotFile();
|
||||
manager.setSnapshot('foo 1', 'foo value');
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), false);
|
||||
manager.writeSnapshotFile();
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), true);
|
||||
});
|
||||
|
||||
test('creates snapshot directory if it does not exist', (t) => {
|
||||
const testFile = tmpdir.resolve('foo/bar/baz/test2.js');
|
||||
const manager = new SnapshotManager(testFile, true);
|
||||
manager.resolveSnapshotFile();
|
||||
manager.setSnapshot('foo 1', 'foo value');
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), false);
|
||||
manager.writeSnapshotFile();
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), true);
|
||||
});
|
||||
|
||||
test('does not write snapshot file in read mode', (t) => {
|
||||
const testFile = tmpdir.resolve('test3.js');
|
||||
const manager = new SnapshotManager(testFile, false);
|
||||
manager.resolveSnapshotFile();
|
||||
manager.setSnapshot('foo 1', 'foo value');
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), false);
|
||||
manager.writeSnapshotFile();
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), false);
|
||||
});
|
||||
|
||||
test('throws if snapshot file cannot be written', (t) => {
|
||||
const testFile = tmpdir.resolve('test4.js');
|
||||
const error = new Error('boom');
|
||||
const manager = new SnapshotManager(testFile, true);
|
||||
manager.resolveSnapshotFile();
|
||||
manager.snapshots['foo 1'] = { toString() { throw error; } };
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), false);
|
||||
t.assert.throws(() => {
|
||||
manager.writeSnapshotFile();
|
||||
}, (err) => {
|
||||
t.assert.strictEqual(err.code, 'ERR_INVALID_STATE');
|
||||
t.assert.match(err.message, /Cannot write snapshot file/);
|
||||
t.assert.strictEqual(err.filename, manager.snapshotFile);
|
||||
t.assert.strictEqual(err.cause, error);
|
||||
return true;
|
||||
});
|
||||
|
||||
t.assert.strictEqual(fs.existsSync(manager.snapshotFile), false);
|
||||
});
|
||||
});
|
||||
|
||||
suite('t.assert.snapshot() validation', () => {
|
||||
test('options must be an object', (t) => {
|
||||
t.assert.throws(() => {
|
||||
t.assert.snapshot('', null);
|
||||
}, /The "options" argument must be of type object/);
|
||||
});
|
||||
|
||||
test('options.serializers must be an array if present', (t) => {
|
||||
t.assert.throws(() => {
|
||||
t.assert.snapshot('', { 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.snapshot('', { serializers: [() => {}, ''] });
|
||||
}, /The "options\.serializers\[1\]" property must be of type function/);
|
||||
});
|
||||
});
|
||||
|
||||
suite('setResolveSnapshotPath()', () => {
|
||||
test('throws if input is not a function', (t) => {
|
||||
t.assert.throws(() => {
|
||||
snapshot.setResolveSnapshotPath('');
|
||||
}, { code: 'ERR_INVALID_ARG_TYPE' });
|
||||
});
|
||||
|
||||
test('changes default snapshot output path', (t) => {
|
||||
t.after(() => {
|
||||
snapshot.setResolveSnapshotPath(defaultResolveSnapshotPath);
|
||||
});
|
||||
|
||||
snapshot.setResolveSnapshotPath(() => { return 'foobarbaz'; });
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
manager.resolveSnapshotFile();
|
||||
t.assert.strictEqual(manager.snapshotFile, 'foobarbaz');
|
||||
});
|
||||
});
|
||||
|
||||
suite('setDefaultSnapshotSerializers()', () => {
|
||||
test('throws if input is not a function array', (t) => {
|
||||
t.assert.throws(() => {
|
||||
snapshot.setDefaultSnapshotSerializers('');
|
||||
}, { code: 'ERR_INVALID_ARG_TYPE' });
|
||||
t.assert.throws(() => {
|
||||
snapshot.setDefaultSnapshotSerializers([5]);
|
||||
}, { code: 'ERR_INVALID_ARG_TYPE' });
|
||||
});
|
||||
|
||||
test('changes default serializers', (t) => {
|
||||
t.after(() => {
|
||||
snapshot.setDefaultSnapshotSerializers(defaultSerializers);
|
||||
});
|
||||
|
||||
snapshot.setDefaultSnapshotSerializers([() => { return 'foobarbaz'; }]);
|
||||
const manager = new SnapshotManager(__filename, false);
|
||||
const output = manager.serialize({ foo: 1 });
|
||||
t.assert.strictEqual(output, '\nfoobarbaz\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('t.assert.snapshot()', async (t) => {
|
||||
const fixture = fixtures.path(
|
||||
'test-runner', 'snapshots', 'unit.js'
|
||||
);
|
||||
|
||||
await t.test('fails prior to snapshot generation', async (t) => {
|
||||
const child = await common.spawnPromisified(
|
||||
process.execPath,
|
||||
['--experimental-test-snapshots', 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/);
|
||||
});
|
||||
|
||||
await t.test('passes when regenerating snapshots', async (t) => {
|
||||
const child = await common.spawnPromisified(
|
||||
process.execPath,
|
||||
['--test-update-snapshots', '--experimental-test-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/);
|
||||
});
|
||||
|
||||
await t.test('passes when snapshots exist', async (t) => {
|
||||
const child = await common.spawnPromisified(
|
||||
process.execPath,
|
||||
['--experimental-test-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/);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue