Skip to content

Commit 61be2da

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

File tree

5 files changed

+185
-3
lines changed

5 files changed

+185
-3
lines changed

doc/api/test.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2965,6 +2965,29 @@ added:
29652965

29662966
The name of the test.
29672967

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

29702993
<!-- YAML

lib/internal/test_runner/test.js

Lines changed: 67 additions & 3 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;
@@ -373,6 +436,7 @@ 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;
376440
this.cancelled = false;
377441
this.skipped = skip !== undefined && skip !== false;
378442
this.isTodo = todo !== undefined && todo !== false;
@@ -754,7 +818,7 @@ class Test extends AsyncResource {
754818
this.postRun();
755819
return;
756820
}
757-
821+
this.plan?.check();
758822
this.pass();
759823
await afterEach();
760824
await after();
@@ -910,7 +974,7 @@ class Test extends AsyncResource {
910974
this.finished = true;
911975

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

10091073
// Report failures in the root test's after() hook.
10101074
if (error && parent !== null &&
1011-
parent === parent.root && this.hookType === 'after') {
1075+
parent === parent.root && this.hookType === 'after') {
10121076

10131077
if (isTestFailureError(error)) {
10141078
error.failureType = kHookFailure;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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+
1..5
52+
# tests 7
53+
# suites 0
54+
# pass 4
55+
# fail 3
56+
# cancelled 0
57+
# skipped 0
58+
# todo 0
59+
# 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)