Skip to content

Commit e76e07b

Browse files
committed
test_runner: add t.assert.fileSnapshot()
This commit adds a t.assert.fileSnapshot() API to the test runner. This is similar to how snapshot tests work in core, as well as userland options such as toMatchFileSnapshot().
1 parent 01554f3 commit e76e07b

File tree

6 files changed

+229
-29
lines changed

6 files changed

+229
-29
lines changed

‎doc/api/test.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3255,6 +3255,43 @@ test('test', (t) =>{
32553255
});
32563256
```
32573257

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

32603297
<!-- YAML

‎lib/internal/test_runner/snapshot.js‎

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

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

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

@@ -222,6 +207,80 @@ class SnapshotManager{
222207
}
223208
};
224209
}
210+
211+
createFileAssert(){
212+
constmanager=this;
213+
214+
returnfunctionfileSnapshotAssertion(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+
constvalue=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+
letexpected;
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+
functionthrowReadError(err,filename){
246+
letmsg=`Cannot read snapshot file '${filename}.'`;
247+
248+
if(err?.code==='ENOENT'){
249+
msg+=` ${kMissingSnapshotTip}`;
250+
}
251+
252+
consterror=newERR_INVALID_STATE(msg);
253+
error.cause=err;
254+
error.filename=filename;
255+
throwerror;
256+
}
257+
258+
functionthrowWriteError(err,filename){
259+
constmsg=`Cannot write snapshot file '${filename}.'`;
260+
consterror=newERR_INVALID_STATE(msg);
261+
error.cause=err;
262+
error.filename=filename;
263+
throwerror;
264+
}
265+
266+
functionthrowSerializationError(input,err){
267+
consterror=newERR_INVALID_STATE(
268+
'The provided serializers did not generate a string.',
269+
);
270+
error.input=input;
271+
error.cause=err;
272+
throwerror;
273+
}
274+
275+
functionserializeValue(value,serializers){
276+
letv=value;
277+
278+
for(leti=0;i<serializers.length;++i){
279+
constfn=serializers[i];
280+
v=fn(v);
281+
}
282+
283+
returnv;
225284
}
226285

227286
functionvalidateFunctionArray(fns,name){

‎lib/internal/test_runner/test.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ function lazyAssertObject(harness){
128128

129129
harness.snapshotManager=newSnapshotManager(harness.config.updateSnapshots);
130130
assertObj.set('snapshot',harness.snapshotManager.createAssert());
131+
assertObj.set('fileSnapshot',harness.snapshotManager.createFileAssert());
131132
}
132133
returnassertObj;
133134
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
const{ test }=require('node:test');
3+
4+
test('snapshot file path is created',(t)=>{
5+
t.assert.fileSnapshot({baz: 9},'./foo/bar/baz/1.json');
6+
});
7+
8+
test('test with plan',(t)=>{
9+
t.plan(2);
10+
t.assert.fileSnapshot({foo: 1,bar: 2},'2.json');
11+
t.assert.fileSnapshot(5,'3.txt');
12+
});
13+
14+
test('custom serializers are supported',(t)=>{
15+
t.assert.fileSnapshot({foo: 1},'4.txt',{
16+
serializers: [
17+
(value)=>{returnvalue+'424242';},
18+
(value)=>{returnJSON.stringify(value);},
19+
]
20+
});
21+
});

‎test/parallel/test-runner-assert.js‎

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use strict';
2+
constcommon=require('../common');
3+
constfixtures=require('../common/fixtures');
4+
consttmpdir=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"argumentmustbeoftypestring/);
14+
});
15+
16+
test('options must be an object',(t)=>{
17+
t.assert.throws(()=>{
18+
t.assert.fileSnapshot({},'',null);
19+
},/The"options"argumentmustbeoftypeobject/);
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"propertymustbeaninstanceofArray/);
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\]"propertymustbeoftypefunction/);
32+
});
33+
});
34+
35+
suite('t.assert.fileSnapshot() update/read flow',()=>{
36+
constfixture=fixtures.path(
37+
'test-runner','snapshots','file-snapshots.js'
38+
);
39+
40+
test('fails prior to snapshot generation',async(t)=>{
41+
constchild=awaitcommon.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,/tests3/);
50+
t.assert.match(child.stdout,/pass0/);
51+
t.assert.match(child.stdout,/fail3/);
52+
t.assert.match(child.stdout,/Missingsnapshotscanbegenerated/);
53+
});
54+
55+
test('passes when regenerating snapshots',async(t)=>{
56+
constchild=awaitcommon.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,/tests3/);
65+
t.assert.match(child.stdout,/pass3/);
66+
t.assert.match(child.stdout,/fail0/);
67+
});
68+
69+
test('passes when snapshots exist',async(t)=>{
70+
constchild=awaitcommon.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,/tests3/);
79+
t.assert.match(child.stdout,/pass3/);
80+
t.assert.match(child.stdout,/fail0/);
81+
});
82+
});

0 commit comments

Comments
(0)