Skip to content

Commit 2f59529

Browse files
cjihrigmarco-ippolito
authored andcommitted
test_runner: support test plans
Co-Authored-By: Marco Ippolito <marcoippolito54@gmail.com> PR-URL: #52860 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Paolo Insogna <paolo@cowtech.it>
1 parent f74beb5 commit 2f59529

File tree

6 files changed

+321
-4
lines changed

6 files changed

+321
-4
lines changed

doc/api/test.md

+57-1
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,10 @@ changes:
13641364
* `timeout` {number} A number of milliseconds the test will fail after.
13651365
If unspecified, subtests inherit this value from their parent.
13661366
**Default:** `Infinity`.
1367+
* `plan` {number} The number of assertions and subtests expected to be run in the test.
1368+
If the number of assertions run in the test does not match the number
1369+
specified in the plan, the test will fail.
1370+
**Default:** `undefined`.
13671371
* `fn` {Function|AsyncFunction} The function under test. The first argument
13681372
to this function is a [`TestContext`][] object. If the test uses callbacks,
13691373
the callback function is passed as the second argument. **Default:** A no-op
@@ -2965,6 +2969,54 @@ added:
29652969

29662970
The name of the test.
29672971

2972+
### `context.plan(count)`
2973+
2974+
<!-- YAML
2975+
added:
2976+
- REPLACEME
2977+
-->
2978+
2979+
> Stability: 1 - Experimental
2980+
2981+
* `count` {number} The number of assertions and subtests that are expected to run.
2982+
2983+
This function is used to set the number of assertions and subtests that are expected to run
2984+
within the test. If the number of assertions and subtests that run does not match the
2985+
expected count, the test will fail.
2986+
2987+
> Note: To make sure assertions are tracked, `t.assert` must be used instead of `assert` directly.
2988+
2989+
```js
2990+
test('top level test', (t) => {
2991+
t.plan(2);
2992+
t.assert.ok('some relevant assertion here');
2993+
t.subtest('subtest', () => {});
2994+
});
2995+
```
2996+
2997+
When working with asynchronous code, the `plan` function can be used to ensure that the
2998+
correct number of assertions are run:
2999+
3000+
```js
3001+
test('planning with streams', (t, done) => {
3002+
function* generate() {
3003+
yield 'a';
3004+
yield 'b';
3005+
yield 'c';
3006+
}
3007+
const expected = ['a', 'b', 'c'];
3008+
t.plan(expected.length);
3009+
const stream = Readable.from(generate());
3010+
stream.on('data', (chunk) => {
3011+
t.assert.strictEqual(chunk, expected.shift());
3012+
});
3013+
3014+
stream.on('end', () => {
3015+
done();
3016+
});
3017+
});
3018+
```
3019+
29683020
### `context.runOnly(shouldRunOnlyTests)`
29693021

29703022
<!-- YAML
@@ -3095,6 +3147,10 @@ changes:
30953147
* `timeout` {number} A number of milliseconds the test will fail after.
30963148
If unspecified, subtests inherit this value from their parent.
30973149
**Default:** `Infinity`.
3150+
* `plan` {number} The number of assertions and subtests expected to be run in the test.
3151+
If the number of assertions run in the test does not match the number
3152+
specified in the plan, the test will fail.
3153+
**Default:** `undefined`.
30983154
* `fn` {Function|AsyncFunction} The function under test. The first argument
30993155
to this function is a [`TestContext`][] object. If the test uses callbacks,
31003156
the callback function is passed as the second argument. **Default:** A no-op
@@ -3108,7 +3164,7 @@ behaves in the same fashion as the top level [`test()`][] function.
31083164
test('top level test', async (t) => {
31093165
await t.test(
31103166
'This is a subtest',
3111-
{ only: false, skip: false, concurrency: 1, todo: false },
3167+
{ only: false, skip: false, concurrency: 1, todo: false, plan: 4 },
31123168
(t) => {
31133169
assert.ok('some relevant assertion here');
31143170
},

lib/internal/test_runner/runner.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ function run(options = kEmptyObject) {
462462
watch,
463463
setup,
464464
only,
465+
plan,
465466
} = options;
466467

467468
if (files != null) {
@@ -534,7 +535,7 @@ function run(options = kEmptyObject) {
534535
});
535536
}
536537

537-
const root = createTestTree({ __proto__: null, concurrency, timeout, signal });
538+
const root = createTestTree({ __proto__: null, concurrency, timeout, signal, plan });
538539
root.harness.shouldColorizeTestFiles ||= shouldColorizeTestFiles(root);
539540

540541
if (process.env.NODE_TEST_CONTEXT !== undefined) {

lib/internal/test_runner/test.js

+77-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const {
1212
MathMax,
1313
Number,
1414
ObjectDefineProperty,
15+
ObjectEntries,
1516
ObjectSeal,
1617
PromisePrototypeThen,
1718
PromiseResolve,
@@ -88,6 +89,7 @@ const {
8889
testOnlyFlag,
8990
} = parseCommandLine();
9091
let kResistStopPropagation;
92+
let assertObj;
9193
let findSourceMap;
9294
let noopTestStream;
9395

@@ -101,6 +103,19 @@ function lazyFindSourceMap(file) {
101103
return findSourceMap(file);
102104
}
103105

106+
function lazyAssertObject() {
107+
if (assertObj === undefined) {
108+
assertObj = new SafeMap();
109+
const assert = require('assert');
110+
for (const { 0: key, 1: value } of ObjectEntries(assert)) {
111+
if (typeof value === 'function') {
112+
assertObj.set(value, key);
113+
}
114+
}
115+
}
116+
return assertObj;
117+
}
118+
104119
function stopTest(timeout, signal) {
105120
const deferred = createDeferredPromise();
106121
const abortListener = addAbortListener(signal, deferred.resolve);
@@ -153,7 +168,25 @@ function testMatchesPattern(test, patterns) {
153168
);
154169
}
155170

171+
class TestPlan {
172+
constructor(count) {
173+
validateUint32(count, 'count', 0);
174+
this.expected = count;
175+
this.actual = 0;
176+
}
177+
178+
check() {
179+
if (this.actual !== this.expected) {
180+
throw new ERR_TEST_FAILURE(
181+
`plan expected ${this.expected} assertions but received ${this.actual}`,
182+
kTestCodeFailure,
183+
);
184+
}
185+
}
186+
}
187+
156188
class TestContext {
189+
#assert;
157190
#test;
158191

159192
constructor(test) {
@@ -180,6 +213,36 @@ class TestContext {
180213
this.#test.diagnostic(message);
181214
}
182215

216+
plan(count) {
217+
if (this.#test.plan !== null) {
218+
throw new ERR_TEST_FAILURE(
219+
'cannot set plan more than once',
220+
kTestCodeFailure,
221+
);
222+
}
223+
224+
this.#test.plan = new TestPlan(count);
225+
}
226+
227+
get assert() {
228+
if (this.#assert === undefined) {
229+
const { plan } = this.#test;
230+
const assertions = lazyAssertObject();
231+
const assert = { __proto__: null };
232+
233+
this.#assert = assert;
234+
for (const { 0: method, 1: name } of assertions.entries()) {
235+
assert[name] = (...args) => {
236+
if (plan !== null) {
237+
plan.actual++;
238+
}
239+
return ReflectApply(method, assert, args);
240+
};
241+
}
242+
}
243+
return this.#assert;
244+
}
245+
183246
get mock() {
184247
this.#test.mock ??= new MockTracker();
185248
return this.#test.mock;
@@ -203,6 +266,11 @@ class TestContext {
203266
loc: getCallerLocation(),
204267
};
205268

269+
const { plan } = this.#test;
270+
if (plan !== null) {
271+
plan.actual++;
272+
}
273+
206274
const subtest = this.#test.createSubtest(
207275
// eslint-disable-next-line no-use-before-define
208276
Test, name, options, fn, overrides,
@@ -257,7 +325,7 @@ class Test extends AsyncResource {
257325
super('Test');
258326

259327
let { fn, name, parent } = options;
260-
const { concurrency, loc, only, timeout, todo, skip, signal } = options;
328+
const { concurrency, loc, only, timeout, todo, skip, signal, plan } = options;
261329

262330
if (typeof fn !== 'function') {
263331
fn = noop;
@@ -373,6 +441,8 @@ class Test extends AsyncResource {
373441
this.fn = fn;
374442
this.harness = null; // Configured on the root test by the test harness.
375443
this.mock = null;
444+
this.plan = null;
445+
this.expectedAssertions = plan;
376446
this.cancelled = false;
377447
this.skipped = skip !== undefined && skip !== false;
378448
this.isTodo = todo !== undefined && todo !== false;
@@ -703,6 +773,11 @@ class Test extends AsyncResource {
703773

704774
const hookArgs = this.getRunArgs();
705775
const { args, ctx } = hookArgs;
776+
777+
if (this.plan === null && this.expectedAssertions) {
778+
ctx.plan(this.expectedAssertions);
779+
}
780+
706781
const after = async () => {
707782
if (this.hooks.after.length > 0) {
708783
await this.runHook('after', hookArgs);
@@ -754,7 +829,7 @@ class Test extends AsyncResource {
754829
this.postRun();
755830
return;
756831
}
757-
832+
this.plan?.check();
758833
this.pass();
759834
await afterEach();
760835
await after();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use strict';
2+
const { test } = require('node:test');
3+
const { Readable } = require('node:stream');
4+
5+
test('test planning basic', (t) => {
6+
t.plan(2);
7+
t.assert.ok(true);
8+
t.assert.ok(true);
9+
});
10+
11+
test('less assertions than planned', (t) => {
12+
t.plan(1);
13+
});
14+
15+
test('more assertions than planned', (t) => {
16+
t.plan(1);
17+
t.assert.ok(true);
18+
t.assert.ok(true);
19+
});
20+
21+
test('subtesting', (t) => {
22+
t.plan(1);
23+
t.test('subtest', () => { });
24+
});
25+
26+
test('subtesting correctly', (t) => {
27+
t.plan(2);
28+
t.assert.ok(true);
29+
t.test('subtest', (st) => {
30+
st.plan(1);
31+
st.assert.ok(true);
32+
});
33+
});
34+
35+
test('correctly ignoring subtesting plan', (t) => {
36+
t.plan(1);
37+
t.test('subtest', (st) => {
38+
st.plan(1);
39+
st.assert.ok(true);
40+
});
41+
});
42+
43+
test('failing planning by options', { plan: 1 }, () => {
44+
});
45+
46+
test('not failing planning by options', { plan: 1 }, (t) => {
47+
t.assert.ok(true);
48+
});
49+
50+
test('subtest planning by options', (t) => {
51+
t.test('subtest', { plan: 1 }, (st) => {
52+
st.assert.ok(true);
53+
});
54+
});
55+
56+
test('failing more assertions than planned', (t) => {
57+
t.plan(2);
58+
t.assert.ok(true);
59+
t.assert.ok(true);
60+
t.assert.ok(true);
61+
});
62+
63+
test('planning with streams', (t, done) => {
64+
function* generate() {
65+
yield 'a';
66+
yield 'b';
67+
yield 'c';
68+
}
69+
const expected = ['a', 'b', 'c'];
70+
t.plan(expected.length);
71+
const stream = Readable.from(generate());
72+
stream.on('data', (chunk) => {
73+
t.assert.strictEqual(chunk, expected.shift());
74+
});
75+
76+
stream.on('end', () => {
77+
done();
78+
});
79+
})

0 commit comments

Comments
 (0)