Skip to content

Commit 52124af

Browse files
test_runner: support test plans
Co-Authored-By: Marco Ippolito <[email protected]> PR-URL: #52860 Reviewed-By: Matteo Collina <[email protected]> Reviewed-By: Paolo Insogna <[email protected]>
1 parent cb18280 commit 52124af

File tree

6 files changed

+334
-4
lines changed

6 files changed

+334
-4
lines changed

doc/api/test.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,10 @@ changes:
13251325
* `timeout` {number} A number of milliseconds the test will fail after.
13261326
If unspecified, subtests inherit this value from their parent.
13271327
**Default:** `Infinity`.
1328+
* `plan` {number} The number of assertions and subtests expected to be run in the test.
1329+
If the number of assertions run in the test does not match the number
1330+
specified in the plan, the test will fail.
1331+
**Default:** `undefined`.
13281332
* `fn` {Function|AsyncFunction} The function under test. The first argument
13291333
to this function is a [`TestContext`][] object. If the test uses callbacks,
13301334
the callback function is passed as the second argument. **Default:** A no-op
@@ -2912,6 +2916,54 @@ added:
29122916

29132917
The name of the test.
29142918

2919+
### `context.plan(count)`
2920+
2921+
<!-- YAML
2922+
added:
2923+
- REPLACEME
2924+
-->
2925+
2926+
> Stability: 1 - Experimental
2927+
2928+
* `count` {number} The number of assertions and subtests that are expected to run.
2929+
2930+
This function is used to set the number of assertions and subtests that are expected to run
2931+
within the test. If the number of assertions and subtests that run does not match the
2932+
expected count, the test will fail.
2933+
2934+
> Note: To make sure assertions are tracked, `t.assert` must be used instead of `assert` directly.
2935+
2936+
```js
2937+
test('top level test', (t) => {
2938+
t.plan(2);
2939+
t.assert.ok('some relevant assertion here');
2940+
t.subtest('subtest', () => {});
2941+
});
2942+
```
2943+
2944+
When working with asynchronous code, the `plan` function can be used to ensure that the
2945+
correct number of assertions are run:
2946+
2947+
```js
2948+
test('planning with streams', (t, done) => {
2949+
function* generate() {
2950+
yield 'a';
2951+
yield 'b';
2952+
yield 'c';
2953+
}
2954+
const expected = ['a', 'b', 'c'];
2955+
t.plan(expected.length);
2956+
const stream = Readable.from(generate());
2957+
stream.on('data', (chunk) => {
2958+
t.assert.strictEqual(chunk, expected.shift());
2959+
});
2960+
2961+
stream.on('end', () => {
2962+
done();
2963+
});
2964+
});
2965+
```
2966+
29152967
### `context.runOnly(shouldRunOnlyTests)`
29162968

29172969
<!-- YAML
@@ -3042,6 +3094,10 @@ changes:
30423094
* `timeout` {number} A number of milliseconds the test will fail after.
30433095
If unspecified, subtests inherit this value from their parent.
30443096
**Default:** `Infinity`.
3097+
* `plan` {number} The number of assertions and subtests expected to be run in the test.
3098+
If the number of assertions run in the test does not match the number
3099+
specified in the plan, the test will fail.
3100+
**Default:** `undefined`.
30453101
* `fn` {Function|AsyncFunction} The function under test. The first argument
30463102
to this function is a [`TestContext`][] object. If the test uses callbacks,
30473103
the callback function is passed as the second argument. **Default:** A no-op
@@ -3055,7 +3111,7 @@ behaves in the same fashion as the top level [`test()`][] function.
30553111
test('top level test', async (t) => {
30563112
await t.test(
30573113
'This is a subtest',
3058-
{ only: false, skip: false, concurrency: 1, todo: false },
3114+
{ only: false, skip: false, concurrency: 1, todo: false, plan: 4 },
30593115
(t) => {
30603116
assert.ok('some relevant assertion here');
30613117
},

lib/internal/test_runner/runner.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,7 @@ function run(options) {
509509
watch,
510510
setup,
511511
only,
512+
plan,
512513
} = options;
513514

514515
if (files != null) {
@@ -565,7 +566,7 @@ function run(options) {
565566
});
566567
}
567568

568-
const root = createTestTree({ __proto__: null, concurrency, timeout, signal });
569+
const root = createTestTree({ __proto__: null, concurrency, timeout, signal, plan });
569570
root.harness.shouldColorizeTestFiles ||= shouldColorizeTestFiles(root);
570571

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

lib/internal/test_runner/test.js

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
FunctionPrototype,
1212
MathMax,
1313
Number,
14+
ObjectEntries,
1415
ObjectSeal,
1516
PromisePrototypeThen,
1617
PromiseResolve,
@@ -85,6 +86,7 @@ const {
8586
testOnlyFlag,
8687
} = parseCommandLine();
8788
let kResistStopPropagation;
89+
let assertObj;
8890
let findSourceMap;
8991

9092
const kRunOnceOptions = { __proto__: null, preserveReturnValue: true };
@@ -97,6 +99,19 @@ function lazyFindSourceMap(file) {
9799
return findSourceMap(file);
98100
}
99101

102+
function lazyAssertObject() {
103+
if (assertObj === undefined) {
104+
assertObj = new SafeMap();
105+
const assert = require('assert');
106+
for (const { 0: key, 1: value } of ObjectEntries(assert)) {
107+
if (typeof value === 'function') {
108+
assertObj.set(value, key);
109+
}
110+
}
111+
}
112+
return assertObj;
113+
}
114+
100115
function stopTest(timeout, signal) {
101116
const deferred = createDeferredPromise();
102117
const abortListener = addAbortListener(signal, deferred.resolve);
@@ -136,7 +151,38 @@ function stopTest(timeout, signal) {
136151
return deferred.promise;
137152
}
138153

154+
function testMatchesPattern(test, patterns) {
155+
const matchesByNameOrParent = ArrayPrototypeSome(patterns, (re) =>
156+
RegExpPrototypeExec(re, test.name) !== null,
157+
) || (test.parent && testMatchesPattern(test.parent, patterns));
158+
if (matchesByNameOrParent) return true;
159+
160+
const testNameWithAncestors = StringPrototypeTrim(test.getTestNameWithAncestors());
161+
162+
return ArrayPrototypeSome(patterns, (re) =>
163+
RegExpPrototypeExec(re, testNameWithAncestors) !== null,
164+
);
165+
}
166+
167+
class TestPlan {
168+
constructor(count) {
169+
validateUint32(count, 'count', 0);
170+
this.expected = count;
171+
this.actual = 0;
172+
}
173+
174+
check() {
175+
if (this.actual !== this.expected) {
176+
throw new ERR_TEST_FAILURE(
177+
`plan expected ${this.expected} assertions but received ${this.actual}`,
178+
kTestCodeFailure,
179+
);
180+
}
181+
}
182+
}
183+
139184
class TestContext {
185+
#assert;
140186
#test;
141187

142188
constructor(test) {
@@ -163,6 +209,36 @@ class TestContext {
163209
this.#test.diagnostic(message);
164210
}
165211

212+
plan(count) {
213+
if (this.#test.plan !== null) {
214+
throw new ERR_TEST_FAILURE(
215+
'cannot set plan more than once',
216+
kTestCodeFailure,
217+
);
218+
}
219+
220+
this.#test.plan = new TestPlan(count);
221+
}
222+
223+
get assert() {
224+
if (this.#assert === undefined) {
225+
const { plan } = this.#test;
226+
const assertions = lazyAssertObject();
227+
const assert = { __proto__: null };
228+
229+
this.#assert = assert;
230+
for (const { 0: method, 1: name } of assertions.entries()) {
231+
assert[name] = (...args) => {
232+
if (plan !== null) {
233+
plan.actual++;
234+
}
235+
return ReflectApply(method, assert, args);
236+
};
237+
}
238+
}
239+
return this.#assert;
240+
}
241+
166242
get mock() {
167243
this.#test.mock ??= new MockTracker();
168244
return this.#test.mock;
@@ -186,6 +262,11 @@ class TestContext {
186262
loc: getCallerLocation(),
187263
};
188264

265+
const { plan } = this.#test;
266+
if (plan !== null) {
267+
plan.actual++;
268+
}
269+
189270
const subtest = this.#test.createSubtest(
190271
// eslint-disable-next-line no-use-before-define
191272
Test, name, options, fn, overrides,
@@ -240,7 +321,7 @@ class Test extends AsyncResource {
240321
super('Test');
241322

242323
let { fn, name, parent, skip } = options;
243-
const { concurrency, loc, only, timeout, todo, signal } = options;
324+
const { concurrency, loc, only, timeout, todo, signal, plan } = options;
244325

245326
if (typeof fn !== 'function') {
246327
fn = noop;
@@ -351,6 +432,8 @@ class Test extends AsyncResource {
351432
this.fn = fn;
352433
this.harness = null; // Configured on the root test by the test harness.
353434
this.mock = null;
435+
this.plan = null;
436+
this.expectedAssertions = plan;
354437
this.cancelled = false;
355438
this.skipped = skip !== undefined && skip !== false;
356439
this.isTodo = todo !== undefined && todo !== false;
@@ -643,6 +726,11 @@ class Test extends AsyncResource {
643726

644727
const hookArgs = this.getRunArgs();
645728
const { args, ctx } = hookArgs;
729+
730+
if (this.plan === null && this.expectedAssertions) {
731+
ctx.plan(this.expectedAssertions);
732+
}
733+
646734
const after = async () => {
647735
if (this.hooks.after.length > 0) {
648736
await this.runHook('after', hookArgs);
@@ -694,7 +782,7 @@ class Test extends AsyncResource {
694782
this.postRun();
695783
return;
696784
}
697-
785+
this.plan?.check();
698786
this.pass();
699787
await afterEach();
700788
await after();
Lines changed: 79 additions & 0 deletions
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)