GeezerVison is showing github.com — test_runner: add t.assert.fileSnapshot() · nodejs/node@00a1943
File treeExpand file treeCollapse file tree Open diff view settings fixtures/test-runner/snapshots
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: doc/api/test.md +37Lines changed: 37 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -3278,6 +3278,43 @@ test('test', (t) => { | 3278 | 3278 | }); | 3279 | 3279 | ``` | 3280 | 3280 |
| | 3281 | +#### `context.assert.fileSnapshot(value, path[, options])` | | 3282 | + | | 3283 | +<!-- YAML | | 3284 | +added: REPLACEME | | 3285 | +--> | | 3286 | + | | 3287 | +* `value` {any} A value to serialize to a string. If Node.js was started with | | 3288 | + the [`--test-update-snapshots`][] flag, the serialized value is written to | | 3289 | + `path`. Otherwise, the serialized value is compared to the contents of the | | 3290 | + existing snapshot file. | | 3291 | +* `path` {string} The file where the serialized `value` is written. | | 3292 | +* `options` {Object} Optional configuration options. The following properties | | 3293 | + are supported: | | 3294 | + * `serializers` {Array} An array of synchronous functions used to serialize | | 3295 | + `value` into a string. `value` is passed as the only argument to the first | | 3296 | + serializer function. The return value of each serializer is passed as input | | 3297 | + to the next serializer. Once all serializers have run, the resulting value | | 3298 | + is coerced to a string. **Default:** If no serializers are provided, the | | 3299 | + test runner's default serializers are used. | | 3300 | + | | 3301 | +This function serializes `value` and writes it to the file specified by `path`. | | 3302 | + | | 3303 | +```js | | 3304 | +test('snapshot test with default serialization', (t) => { | | 3305 | + t.assert.fileSnapshot({ value1: 1, value2: 2 }, './snapshots/snapshot.json'); | | 3306 | +}); | | 3307 | +``` | | 3308 | + | | 3309 | +This function differs from `context.assert.snapshot()` in the following ways: | | 3310 | + | | 3311 | +* The snapshot file path is explicitly provided by the user. | | 3312 | +* Each snapshot file is limited to a single snapshot value. | | 3313 | +* No additional escaping is performed by the test runner. | | 3314 | + | | 3315 | +These differences allow snapshot files to better support features such as syntax | | 3316 | +highlighting. | | 3317 | + | 3281 | 3318 | #### `context.assert.snapshot(value[, options])` | 3282 | 3319 |
| 3283 | 3320 | <!-- YAML | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/snapshot.js +87-28Lines changed: 87 additions & 28 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -23,6 +23,7 @@ const { | 23 | 23 | validateArray, | 24 | 24 | validateFunction, | 25 | 25 | validateObject, | | 26 | + validateString, | 26 | 27 | } = require('internal/validators'); | 27 | 28 | const { strictEqual } = require('assert'); | 28 | 29 | const { mkdirSync, readFileSync, writeFileSync } = require('fs'); | @@ -109,16 +110,7 @@ class SnapshotFile { | 109 | 110 | } | 110 | 111 | this.loaded = true; | 111 | 112 | } 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); | 122 | 114 | } | 123 | 115 | } | 124 | 116 |
| @@ -132,11 +124,7 @@ class SnapshotFile { | 132 | 124 | mkdirSync(dirname(this.snapshotFile), { __proto__: null, recursive: true }); | 133 | 125 | writeFileSync(this.snapshotFile, output, 'utf8'); | 134 | 126 | } 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); | 140 | 128 | } | 141 | 129 | } | 142 | 130 | } | @@ -171,21 +159,18 @@ class SnapshotManager { | 171 | 159 |
| 172 | 160 | serialize(input, serializers = serializerFns) { | 173 | 161 | 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); | 181 | 163 | return `\n${templateEscape(value)}\n`; | 182 | 164 | } 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); | 189 | 174 | } | 190 | 175 | } | 191 | 176 |
| @@ -222,6 +207,80 @@ class SnapshotManager { | 222 | 207 | } | 223 | 208 | }; | 224 | 209 | } | | 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; | 225 | 284 | } | 226 | 285 |
| 227 | 286 | function validateFunctionArray(fns, name) { | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/test.js +7-3Lines changed: 7 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -101,14 +101,18 @@ function lazyFindSourceMap(file) { | 101 | 101 | function lazyAssertObject(harness) { | 102 | 102 | if (assertObj === undefined) { | 103 | 103 | const { getAssertionMap } = require('internal/test_runner/assert'); | | 104 | + const { SnapshotManager } = require('internal/test_runner/snapshot'); | 104 | 105 |
| 105 | 106 | assertObj = getAssertionMap(); | 106 | | - if (!assertObj.has('snapshot')) { | 107 | | - const { SnapshotManager } = require('internal/test_runner/snapshot'); | | 107 | + harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots); | 108 | 108 |
| 109 | | - harness.snapshotManager = new SnapshotManager(harness.config.updateSnapshots); | | 109 | + if (!assertObj.has('snapshot')) { | 110 | 110 | assertObj.set('snapshot', harness.snapshotManager.createAssert()); | 111 | 111 | } | | 112 | + | | 113 | + if (!assertObj.has('fileSnapshot')) { | | 114 | + assertObj.set('fileSnapshot', harness.snapshotManager.createFileAssert()); | | 115 | + } | 112 | 116 | } | 113 | 117 | return assertObj; | 114 | 118 | } | |
Collapse file Copy file name to clipboard +21Lines changed: 21 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +}); |
Collapse file Copy file name to clipboardExpand all lines: test/parallel/test-runner-assert.js +1-1Lines changed: 1 addition & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -10,7 +10,7 @@ test('expected methods are on t.assert', (t) => { | 10 | 10 | 'strict', | 11 | 11 | ]; | 12 | 12 | 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(); | 14 | 14 | assert.deepStrictEqual(Object.keys(t.assert).sort(), expectedKeys); | 15 | 15 | }); | 16 | 16 |
| |
Collapse file Copy file name to clipboard +82Lines changed: 82 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +}); |
|