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:
cjihrig 2024-05-18 12:32:41 -04:00
parent 851dcddb57
commit 9f6c12413c
No known key found for this signature in database
GPG Key ID: 7434390BDBE9B9C5
14 changed files with 824 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -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.
.

View File

@ -210,6 +210,7 @@ function setup(root) {
counters: null,
shouldColorizeTestFiles: false,
teardown: exitHandler,
snapshotManager: null,
};
root.harness.resetCounters();
root.startTime = hrtime();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
exports = null;

View File

@ -0,0 +1,6 @@
exports[`foo 1`] = `
{
"bar": 1,
"baz": 2
}
`;

View File

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

View File

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