Skip to content

Commit 8bd4c30

Browse files
test_runner: support test plans
Co-Authored-By: Colin Ihrig <[email protected]>
1 parent f202322 commit 8bd4c30

File tree

6 files changed

+242
-6
lines changed

6 files changed

+242
-6
lines changed

doc/api/test.md

Lines changed: 32 additions & 1 deletion
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 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,29 @@ added:
29652969

29662970
The name of the test.
29672971

2972+
### `context.plan(count)`
2973+
2974+
<!-- YAML
2975+
added:
2976+
- REPLACEME
2977+
-->
2978+
2979+
* `count` {number} The number of assertions that are expected to run.
2980+
2981+
This function is used to set the number of assertions that are expected to run
2982+
within the test. If the number of assertions that run does not match the
2983+
expected count, the test will fail.
2984+
2985+
> Note: To make sure assertion are tracked, it must be used `t.assert` instead of `assert` directly.
2986+
2987+
```js
2988+
test('top level test', (t) => {
2989+
t.plan(2);
2990+
t.assert.ok('some relevant assertion here');
2991+
t.assert.ok('another relevant assertion here');
2992+
});
2993+
```
2994+
29682995
### `context.runOnly(shouldRunOnlyTests)`
29692996

29702997
<!-- YAML
@@ -3095,6 +3122,10 @@ changes:
30953122
* `timeout` {number} A number of milliseconds the test will fail after.
30963123
If unspecified, subtests inherit this value from their parent.
30973124
**Default:** `Infinity`.
3125+
* `plan` {number} The number of assertions expected to be run in the test.
3126+
If the number of assertions run in the test does not match the number
3127+
specified in the plan, the test will fail.
3128+
**Default:** `undefined`.
30983129
* `fn` {Function|AsyncFunction} The function under test. The first argument
30993130
to this function is a [`TestContext`][] object. If the test uses callbacks,
31003131
the callback function is passed as the second argument. **Default:** A no-op
@@ -3108,7 +3139,7 @@ behaves in the same fashion as the top level [`test()`][] function.
31083139
test('top level test', async (t) => {
31093140
await t.test(
31103141
'This is a subtest',
3111-
{ only: false, skip: false, concurrency: 1, todo: false },
3142+
{ only: false, skip: false, concurrency: 1, todo: false, plan: 4 },
31123143
(t) => {
31133144
assert.ok('some relevant assertion here');
31143145
},

lib/internal/test_runner/runner.js

Lines changed: 2 additions & 1 deletion
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

Lines changed: 74 additions & 4 deletions
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;
@@ -257,7 +320,7 @@ class Test extends AsyncResource {
257320
super('Test');
258321

259322
let { fn, name, parent } = options;
260-
const { concurrency, loc, only, timeout, todo, skip, signal } = options;
323+
const { concurrency, loc, only, timeout, todo, skip, signal, plan } = options;
261324

262325
if (typeof fn !== 'function') {
263326
fn = noop;
@@ -373,6 +436,8 @@ class Test extends AsyncResource {
373436
this.fn = fn;
374437
this.harness = null; // Configured on the root test by the test harness.
375438
this.mock = null;
439+
this.plan = null;
440+
this.expectedAssertions = plan;
376441
this.cancelled = false;
377442
this.skipped = skip !== undefined && skip !== false;
378443
this.isTodo = todo !== undefined && todo !== false;
@@ -703,6 +768,11 @@ class Test extends AsyncResource {
703768

704769
const hookArgs = this.getRunArgs();
705770
const { args, ctx } = hookArgs;
771+
772+
if (this.plan === null && this.expectedAssertions) {
773+
ctx.plan(this.expectedAssertions);
774+
}
775+
706776
const after = async () => {
707777
if (this.hooks.after.length > 0) {
708778
await this.runHook('after', hookArgs);
@@ -754,7 +824,7 @@ class Test extends AsyncResource {
754824
this.postRun();
755825
return;
756826
}
757-
827+
this.plan?.check();
758828
this.pass();
759829
await afterEach();
760830
await after();
@@ -910,7 +980,7 @@ class Test extends AsyncResource {
910980
this.finished = true;
911981

912982
if (this.parent === this.root &&
913-
this.root.waitingOn > this.root.subtests.length) {
983+
this.root.waitingOn > this.root.subtests.length) {
914984
// At this point all of the tests have finished running. However, there
915985
// might be ref'ed handles keeping the event loop alive. This gives the
916986
// global after() hook a chance to clean them up. The user may also
@@ -1008,7 +1078,7 @@ class TestHook extends Test {
10081078

10091079
// Report failures in the root test's after() hook.
10101080
if (error && parent !== null &&
1011-
parent === parent.root && this.hookType === 'after') {
1081+
parent === parent.root && this.hookType === 'after') {
10121082

10131083
if (isTestFailureError(error)) {
10141084
error.failureType = kHookFailure;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use strict';
2+
const { test } = require('node:test');
3+
4+
test('test planning basic', (t) => {
5+
t.plan(2);
6+
t.assert.ok(true);
7+
t.assert.ok(true);
8+
});
9+
10+
test('less assertions than planned', (t) => {
11+
t.plan(1);
12+
});
13+
14+
test('more assertions than planned', (t) => {
15+
t.plan(1);
16+
t.assert.ok(true);
17+
t.assert.ok(true);
18+
});
19+
20+
test('subtesting correctly', (t) => {
21+
t.plan(1);
22+
t.assert.ok(true);
23+
t.test('subtest', (st) => {
24+
st.plan(1);
25+
st.assert.ok(true);
26+
});
27+
});
28+
29+
test('correctly ignoring subtesting plan', (t) => {
30+
t.plan(1);
31+
t.test('subtest', (st) => {
32+
st.plan(1);
33+
st.assert.ok(true);
34+
});
35+
});
36+
37+
test('failing planning by options', { plan: 1 }, () => {
38+
});
39+
40+
test('not failing planning by options', { plan: 1 }, (t) => {
41+
t.assert.ok(true);
42+
});
43+
44+
test('subtest planning by options', (t) => {
45+
t.test('subtest', { plan: 1 }, (st) => {
46+
st.assert.ok(true);
47+
});
48+
});
49+
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 correctly
26+
# Subtest: subtest
27+
ok 1 - subtest
28+
---
29+
duration_ms: *
30+
...
31+
1..1
32+
ok 4 - subtesting correctly
33+
---
34+
duration_ms: *
35+
...
36+
# Subtest: correctly ignoring subtesting plan
37+
# Subtest: subtest
38+
ok 1 - subtest
39+
---
40+
duration_ms: *
41+
...
42+
1..1
43+
not ok 5 - correctly ignoring subtesting plan
44+
---
45+
duration_ms: *
46+
location: '/test/fixtures/test-runner/output/test-runner-plan.js:(LINE):1'
47+
failureType: 'testCodeFailure'
48+
error: 'plan expected 1 assertions but received 0'
49+
code: 'ERR_TEST_FAILURE'
50+
...
51+
# Subtest: failing planning by options
52+
not ok 6 - failing planning by options
53+
---
54+
duration_ms: *
55+
location: '/test/fixtures/test-runner/output/test-runner-plan.js:(LINE):1'
56+
failureType: 'testCodeFailure'
57+
error: 'plan expected 1 assertions but received 0'
58+
code: 'ERR_TEST_FAILURE'
59+
...
60+
# Subtest: not failing planning by options
61+
ok 7 - not failing planning by options
62+
---
63+
duration_ms: *
64+
...
65+
# Subtest: subtest planning by options
66+
# Subtest: subtest
67+
ok 1 - subtest
68+
---
69+
duration_ms: *
70+
...
71+
1..1
72+
ok 8 - subtest planning by options
73+
---
74+
duration_ms: *
75+
...
76+
1..8
77+
# tests 11
78+
# suites 0
79+
# pass 7
80+
# fail 4
81+
# cancelled 0
82+
# skipped 0
83+
# todo 0
84+
# duration_ms *

test/parallel/test-runner-output.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ const tests = [
140140
replaceTestDuration,
141141
),
142142
},
143+
{ name: 'test-runner/output/test-runner-plan.js' },
143144
process.features.inspector ? { name: 'test-runner/output/coverage_failure.js' } : false,
144145
]
145146
.filter(Boolean)

0 commit comments

Comments
 (0)