GeezerVison is showing github.com — test_runner: support test plans · nodejs/node@2f59529
File treeExpand file treeCollapse file tree Open diff view settings fixtures/test-runner/output
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: doc/api/test.md +57-1Lines changed: 57 additions & 1 deletion - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -1364,6 +1364,10 @@ changes: | 1364 | 1364 | * `timeout` {number} A number of milliseconds the test will fail after. | 1365 | 1365 | If unspecified, subtests inherit this value from their parent. | 1366 | 1366 | **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`. | 1367 | 1371 | * `fn` {Function|AsyncFunction} The function under test. The first argument | 1368 | 1372 | to this function is a [`TestContext`][] object. If the test uses callbacks, | 1369 | 1373 | the callback function is passed as the second argument. **Default:** A no-op | @@ -2965,6 +2969,54 @@ added: | 2965 | 2969 |
| 2966 | 2970 | The name of the test. | 2967 | 2971 |
| | 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 | + | 2968 | 3020 | ### `context.runOnly(shouldRunOnlyTests)` | 2969 | 3021 |
| 2970 | 3022 | <!-- YAML | @@ -3095,6 +3147,10 @@ changes: | 3095 | 3147 | * `timeout` {number} A number of milliseconds the test will fail after. | 3096 | 3148 | If unspecified, subtests inherit this value from their parent. | 3097 | 3149 | **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`. | 3098 | 3154 | * `fn` {Function|AsyncFunction} The function under test. The first argument | 3099 | 3155 | to this function is a [`TestContext`][] object. If the test uses callbacks, | 3100 | 3156 | 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. | 3108 | 3164 | test('top level test', async (t) => { | 3109 | 3165 | await t.test( | 3110 | 3166 | 'This is a subtest', | 3111 | | - { only: false, skip: false, concurrency: 1, todo: false }, | | 3167 | + { only: false, skip: false, concurrency: 1, todo: false, plan: 4 }, | 3112 | 3168 | (t) => { | 3113 | 3169 | assert.ok('some relevant assertion here'); | 3114 | 3170 | }, | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/runner.js +2-1Lines changed: 2 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -462,6 +462,7 @@ function run(options = kEmptyObject) { | 462 | 462 | watch, | 463 | 463 | setup, | 464 | 464 | only, | | 465 | + plan, | 465 | 466 | } = options; | 466 | 467 |
| 467 | 468 | if (files != null) { | @@ -534,7 +535,7 @@ function run(options = kEmptyObject) { | 534 | 535 | }); | 535 | 536 | } | 536 | 537 |
| 537 | | - const root = createTestTree({ __proto__: null, concurrency, timeout, signal }); | | 538 | + const root = createTestTree({ __proto__: null, concurrency, timeout, signal, plan }); | 538 | 539 | root.harness.shouldColorizeTestFiles ||= shouldColorizeTestFiles(root); | 539 | 540 |
| 540 | 541 | if (process.env.NODE_TEST_CONTEXT !== undefined) { | |
Collapse file Copy file name to clipboardExpand all lines: lib/internal/test_runner/test.js +77-2Lines changed: 77 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -12,6 +12,7 @@ const { | 12 | 12 | MathMax, | 13 | 13 | Number, | 14 | 14 | ObjectDefineProperty, | | 15 | + ObjectEntries, | 15 | 16 | ObjectSeal, | 16 | 17 | PromisePrototypeThen, | 17 | 18 | PromiseResolve, | @@ -88,6 +89,7 @@ const { | 88 | 89 | testOnlyFlag, | 89 | 90 | } = parseCommandLine(); | 90 | 91 | let kResistStopPropagation; | | 92 | +let assertObj; | 91 | 93 | let findSourceMap; | 92 | 94 | let noopTestStream; | 93 | 95 |
| @@ -101,6 +103,19 @@ function lazyFindSourceMap(file) { | 101 | 103 | return findSourceMap(file); | 102 | 104 | } | 103 | 105 |
| | 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 | + | 104 | 119 | function stopTest(timeout, signal) { | 105 | 120 | const deferred = createDeferredPromise(); | 106 | 121 | const abortListener = addAbortListener(signal, deferred.resolve); | @@ -153,7 +168,25 @@ function testMatchesPattern(test, patterns) { | 153 | 168 | ); | 154 | 169 | } | 155 | 170 |
| | 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 | + | 156 | 188 | class TestContext { | | 189 | + #assert; | 157 | 190 | #test; | 158 | 191 |
| 159 | 192 | constructor(test) { | @@ -180,6 +213,36 @@ class TestContext { | 180 | 213 | this.#test.diagnostic(message); | 181 | 214 | } | 182 | 215 |
| | 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 | + | 183 | 246 | get mock() { | 184 | 247 | this.#test.mock ??= new MockTracker(); | 185 | 248 | return this.#test.mock; | @@ -203,6 +266,11 @@ class TestContext { | 203 | 266 | loc: getCallerLocation(), | 204 | 267 | }; | 205 | 268 |
| | 269 | + const { plan } = this.#test; | | 270 | + if (plan !== null) { | | 271 | + plan.actual++; | | 272 | + } | | 273 | + | 206 | 274 | const subtest = this.#test.createSubtest( | 207 | 275 | // eslint-disable-next-line no-use-before-define | 208 | 276 | Test, name, options, fn, overrides, | @@ -257,7 +325,7 @@ class Test extends AsyncResource { | 257 | 325 | super('Test'); | 258 | 326 |
| 259 | 327 | 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; | 261 | 329 |
| 262 | 330 | if (typeof fn !== 'function') { | 263 | 331 | fn = noop; | @@ -373,6 +441,8 @@ class Test extends AsyncResource { | 373 | 441 | this.fn = fn; | 374 | 442 | this.harness = null; // Configured on the root test by the test harness. | 375 | 443 | this.mock = null; | | 444 | + this.plan = null; | | 445 | + this.expectedAssertions = plan; | 376 | 446 | this.cancelled = false; | 377 | 447 | this.skipped = skip !== undefined && skip !== false; | 378 | 448 | this.isTodo = todo !== undefined && todo !== false; | @@ -703,6 +773,11 @@ class Test extends AsyncResource { | 703 | 773 |
| 704 | 774 | const hookArgs = this.getRunArgs(); | 705 | 775 | const { args, ctx } = hookArgs; | | 776 | + | | 777 | + if (this.plan === null && this.expectedAssertions) { | | 778 | + ctx.plan(this.expectedAssertions); | | 779 | + } | | 780 | + | 706 | 781 | const after = async () => { | 707 | 782 | if (this.hooks.after.length > 0) { | 708 | 783 | await this.runHook('after', hookArgs); | @@ -754,7 +829,7 @@ class Test extends AsyncResource { | 754 | 829 | this.postRun(); | 755 | 830 | return; | 756 | 831 | } | 757 | | - | | 832 | + this.plan?.check(); | 758 | 833 | this.pass(); | 759 | 834 | await afterEach(); | 760 | 835 | await after(); | |
Collapse file Copy file name to clipboard +79Lines changed: 79 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +}) |
|