Skip to content

Commit d54aa47

Browse files
cjihrigmarco-ippolito
andcommittedJun 19, 2024
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 da4dbfc commit d54aa47

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

+2-1
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

+77-2
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,25 @@ function stopTest(timeout, signal) {
136151
return deferred.promise;
137152
}
138153

154+
class TestPlan {
155+
constructor(count) {
156+
validateUint32(count, 'count', 0);
157+
this.expected = count;
158+
this.actual = 0;
159+
}
160+
161+
check() {
162+
if (this.actual !== this.expected) {
163+
throw new ERR_TEST_FAILURE(
164+
`plan expected ${this.expected} assertions but received ${this.actual}`,
165+
kTestCodeFailure,
166+
);
167+
}
168+
}
169+
}
170+
139171
class TestContext {
172+
#assert;
140173
#test;
141174

142175
constructor(test) {
@@ -163,6 +196,36 @@ class TestContext {
163196
this.#test.diagnostic(message);
164197
}
165198

199+
plan(count) {
200+
if (this.#test.plan !== null) {
201+
throw new ERR_TEST_FAILURE(
202+
'cannot set plan more than once',
203+
kTestCodeFailure,
204+
);
205+
}
206+
207+
this.#test.plan = new TestPlan(count);
208+
}
209+
210+
get assert() {
211+
if (this.#assert === undefined) {
212+
const { plan } = this.#test;
213+
const assertions = lazyAssertObject();
214+
const assert = { __proto__: null };
215+
216+
this.#assert = assert;
217+
for (const { 0: method, 1: name } of assertions.entries()) {
218+
assert[name] = (...args) => {
219+
if (plan !== null) {
220+
plan.actual++;
221+
}
222+
return ReflectApply(method, assert, args);
223+
};
224+
}
225+
}
226+
return this.#assert;
227+
}
228+
166229
get mock() {
167230
this.#test.mock ??= new MockTracker();
168231
return this.#test.mock;
@@ -186,6 +249,11 @@ class TestContext {
186249
loc: getCallerLocation(),
187250
};
188251

252+
const { plan } = this.#test;
253+
if (plan !== null) {
254+
plan.actual++;
255+
}
256+
189257
const subtest = this.#test.createSubtest(
190258
// eslint-disable-next-line no-use-before-define
191259
Test, name, options, fn, overrides,
@@ -240,7 +308,7 @@ class Test extends AsyncResource {
240308
super('Test');
241309

242310
let { fn, name, parent, skip } = options;
243-
const { concurrency, loc, only, timeout, todo, signal } = options;
311+
const { concurrency, loc, only, timeout, todo, signal, plan } = options;
244312

245313
if (typeof fn !== 'function') {
246314
fn = noop;
@@ -351,6 +419,8 @@ class Test extends AsyncResource {
351419
this.fn = fn;
352420
this.harness = null; // Configured on the root test by the test harness.
353421
this.mock = null;
422+
this.plan = null;
423+
this.expectedAssertions = plan;
354424
this.cancelled = false;
355425
this.skipped = skip !== undefined && skip !== false;
356426
this.isTodo = todo !== undefined && todo !== false;
@@ -643,6 +713,11 @@ class Test extends AsyncResource {
643713

644714
const hookArgs = this.getRunArgs();
645715
const { args, ctx } = hookArgs;
716+
717+
if (this.plan === null && this.expectedAssertions) {
718+
ctx.plan(this.expectedAssertions);
719+
}
720+
646721
const after = async () => {
647722
if (this.hooks.after.length > 0) {
648723
await this.runHook('after', hookArgs);
@@ -694,7 +769,7 @@ class Test extends AsyncResource {
694769
this.postRun();
695770
return;
696771
}
697-
772+
this.plan?.check();
698773
this.pass();
699774
await afterEach();
700775
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+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
TAP version 13
2+
# Subtest: test planning basic
3+
ok 1 - test planning basic
4+
---
5+
duration_ms: *
6+
...
7+
# Subtest: less assertions than planned
8+
not ok 2 - less assertions than planned
9+
---
10+
duration_ms: *
11+
location: '/test/fixtures/test-runner/output/test-runner-plan.js:(LINE):1'
12+
failureType: 'testCodeFailure'
13+
error: 'plan expected 1 assertions but received 0'
14+
code: 'ERR_TEST_FAILURE'
15+
...
16+
# Subtest: more assertions than planned
17+
not ok 3 - more assertions than planned
18+
---
19+
duration_ms: *
20+
location: '/test/fixtures/test-runner/output/test-runner-plan.js:(LINE):1'
21+
failureType: 'testCodeFailure'
22+
error: 'plan expected 1 assertions but received 2'
23+
code: 'ERR_TEST_FAILURE'
24+
...
25+
# Subtest: subtesting
26+
# Subtest: subtest
27+
ok 1 - subtest
28+
---
29+
duration_ms: *
30+
...
31+
1..1
32+
ok 4 - subtesting
33+
---
34+
duration_ms: *
35+
...
36+
# Subtest: subtesting correctly
37+
# Subtest: subtest
38+
ok 1 - subtest
39+
---
40+
duration_ms: *
41+
...
42+
1..1
43+
ok 5 - subtesting correctly
44+
---
45+
duration_ms: *
46+
...
47+
# Subtest: correctly ignoring subtesting plan
48+
# Subtest: subtest
49+
ok 1 - subtest
50+
---
51+
duration_ms: *
52+
...
53+
1..1
54+
ok 6 - correctly ignoring subtesting plan
55+
---
56+
duration_ms: *
57+
...
58+
# Subtest: failing planning by options
59+
not ok 7 - failing planning by options
60+
---
61+
duration_ms: *
62+
location: '/test/fixtures/test-runner/output/test-runner-plan.js:(LINE):1'
63+
failureType: 'testCodeFailure'
64+
error: 'plan expected 1 assertions but received 0'
65+
code: 'ERR_TEST_FAILURE'
66+
...
67+
# Subtest: not failing planning by options
68+
ok 8 - not failing planning by options
69+
---
70+
duration_ms: *
71+
...
72+
# Subtest: subtest planning by options
73+
# Subtest: subtest
74+
ok 1 - subtest
75+
---
76+
duration_ms: *
77+
...
78+
1..1
79+
ok 9 - subtest planning by options
80+
---
81+
duration_ms: *
82+
...
83+
# Subtest: failing more assertions than planned
84+
not ok 10 - failing more assertions than planned
85+
---
86+
duration_ms: *
87+
location: '/test/fixtures/test-runner/output/test-runner-plan.js:(LINE):1'
88+
failureType: 'testCodeFailure'
89+
error: 'plan expected 2 assertions but received 3'
90+
code: 'ERR_TEST_FAILURE'
91+
...
92+
# Subtest: planning with streams
93+
ok 11 - planning with streams
94+
---
95+
duration_ms: *
96+
...
97+
1..11
98+
# tests 15
99+
# suites 0
100+
# pass 11
101+
# fail 4
102+
# cancelled 0
103+
# skipped 0
104+
# todo 0
105+
# duration_ms *

‎test/parallel/test-runner-output.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ const tests = [
138138
replaceTestDuration,
139139
),
140140
},
141+
{ name: 'test-runner/output/test-runner-plan.js' },
141142
process.features.inspector ? { name: 'test-runner/output/coverage_failure.js' } : false,
142143
]
143144
.filter(Boolean)

0 commit comments

Comments
 (0)
Please sign in to comment.