From 4f983d555fe2611ff8478e01a59c3d0939ecfe2e Mon Sep 17 00:00:00 2001 From: Moshe Atlow Date: Thu, 30 Jun 2022 10:14:35 +0300 Subject: [PATCH] test_runner: add before/after/each hooks --- lib/internal/test_runner/harness.js | 11 +++ lib/internal/test_runner/test.js | 99 ++++++++++++++++++++++--- lib/test.js | 6 +- test/message/test_runner_describe_it.js | 6 +- test/parallel/test-runner-hooks.js | 52 +++++++++++++ 5 files changed, 159 insertions(+), 15 deletions(-) create mode 100644 test/parallel/test-runner-hooks.js diff --git a/lib/internal/test_runner/harness.js b/lib/internal/test_runner/harness.js index 1bdd6e99ed1c3b8..5a3c0e18de2e54b 100644 --- a/lib/internal/test_runner/harness.js +++ b/lib/internal/test_runner/harness.js @@ -170,8 +170,19 @@ function runInParentContext(Factory) { return cb; } +function hook(hook) { + return (fn, options) => { + const parent = testResources.get(executionAsyncId()) || setup(root); + parent.createHook(hook, fn, options); + }; +} + module.exports = { test: FunctionPrototypeBind(test, root), describe: runInParentContext(Suite), it: runInParentContext(ItTest), + before: hook('before'), + after: hook('after'), + beforeEach: hook('beforeEach'), + afterEach: hook('afterEach'), }; diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js index 0755c42750ac87e..9aa7e052b66b6f4 100644 --- a/lib/internal/test_runner/test.js +++ b/lib/internal/test_runner/test.js @@ -1,10 +1,13 @@ 'use strict'; const { ArrayPrototypePush, + ArrayPrototypeReduce, ArrayPrototypeShift, + ArrayPrototypeSlice, ArrayPrototypeUnshift, FunctionPrototype, Number, + ObjectSeal, PromisePrototypeThen, PromiseResolve, ReflectApply, @@ -31,7 +34,7 @@ const { kEmptyObject, } = require('internal/util'); const { isPromise } = require('internal/util/types'); -const { isUint32, validateAbortSignal } = require('internal/validators'); +const { isUint32, validateAbortSignal, validateOneOf } = require('internal/validators'); const { setTimeout } = require('timers/promises'); const { cpus } = require('os'); const { bigint: hrtime } = process.hrtime; @@ -50,6 +53,8 @@ const testOnlyFlag = !isTestRunner && getOptionValue('--test-only'); const rootConcurrency = isTestRunner ? cpus().length : 1; const kShouldAbort = Symbol('kShouldAbort'); +const kRunHook = Symbol('kRunHook'); +const kHookNames = ObjectSeal(['before', 'after', 'beforeEach', 'afterEach']); function stopTest(timeout, signal) { @@ -75,6 +80,10 @@ class TestContext { return this.#test.signal; } + get name() { + return this.#test.name; + } + diagnostic(message) { this.#test.diagnostic(message); } @@ -97,6 +106,14 @@ class TestContext { return subtest.start(); } + + beforeEach(fn, options) { + this.#test.createHook('beforeEach', fn, options); + } + + afterEach(fn, options) { + this.#test.createHook('afterEach', fn, options); + } } class Test extends AsyncResource { @@ -185,6 +202,12 @@ class Test extends AsyncResource { this.pendingSubtests = []; this.readySubtests = new SafeMap(); this.subtests = []; + this.hooks = { + before: [], + after: [], + beforeEach: [], + afterEach: [], + }; this.waitingOn = 0; this.finished = false; } @@ -303,10 +326,19 @@ class Test extends AsyncResource { kCancelledByParent ) ); + this.startTime = this.startTime || this.endTime; // If a test was canceled before it was started, e.g inside a hook this.cancelled = true; this.#abortController.abort(); } + createHook(name, fn, options) { + validateOneOf(name, 'hook name', kHookNames); + // eslint-disable-next-line no-use-before-define + const hook = new TestHook(fn, options); + ArrayPrototypePush(this.hooks[name], hook); + return hook; + } + fail(err) { if (this.error !== null) { return; @@ -370,8 +402,18 @@ class Test extends AsyncResource { return { ctx, args: [ctx] }; } + async [kRunHook](hook, args) { + validateOneOf(hook, 'hook name', kHookNames); + await ArrayPrototypeReduce(this.hooks[hook], async (prev, hook) => { + await prev; + await hook.run(args); + }, PromiseResolve()); + } + async run() { - this.parent.activeSubtests++; + if (this.parent !== null) { + this.parent.activeSubtests++; + } this.startTime = hrtime(); if (this[kShouldAbort]()) { @@ -379,17 +421,22 @@ class Test extends AsyncResource { return; } + const { args, ctx } = this.getRunArgs(); + if (this.parent?.hooks.beforeEach.length > 0) { + await this.parent[kRunHook]('beforeEach', { args, ctx }); + } + try { const stopPromise = stopTest(this.timeout, this.signal); - const { args, ctx } = this.getRunArgs(); - ArrayPrototypeUnshift(args, this.fn, ctx); // Note that if it's not OK to mutate args, we need to first clone it. + const runArgs = ArrayPrototypeSlice(args); + ArrayPrototypeUnshift(runArgs, this.fn, ctx); - if (this.fn.length === args.length - 1) { + if (this.fn.length === runArgs.length - 1) { // This test is using legacy Node.js error first callbacks. const { promise, cb } = createDeferredCallback(); - ArrayPrototypePush(args, cb); - const ret = ReflectApply(this.runInAsyncScope, this, args); + ArrayPrototypePush(runArgs, cb); + const ret = ReflectApply(this.runInAsyncScope, this, runArgs); if (isPromise(ret)) { this.fail(new ERR_TEST_FAILURE( @@ -402,7 +449,7 @@ class Test extends AsyncResource { } } else { // This test is synchronous or using Promises. - const promise = ReflectApply(this.runInAsyncScope, this, args); + const promise = ReflectApply(this.runInAsyncScope, this, runArgs); await SafePromiseRace([PromiseResolve(promise), stopPromise]); } @@ -420,6 +467,10 @@ class Test extends AsyncResource { } } + if (this.parent?.hooks.afterEach.length > 0) { + await this.parent[kRunHook]('afterEach', { args, ctx }); + } + // Clean up the test. Then, try to report the results and execute any // tests that were pending due to available concurrency. this.postRun(); @@ -523,10 +574,27 @@ class Test extends AsyncResource { } } +class TestHook extends Test { + #args; + constructor(fn, options) { + if (options === null || typeof options !== 'object') { + options = kEmptyObject; + } + super({ __proto__: null, fn, ...options }); + } + run(args) { + this.#args = args; + return super.run(); + } + getRunArgs() { + return this.#args; + } +} + class ItTest extends Test { constructor(opt) { super(opt); } // eslint-disable-line no-useless-constructor getRunArgs() { - return { ctx: { signal: this.signal }, args: [] }; + return { ctx: { signal: this.signal, name: this.name }, args: [] }; } } class Suite extends Test { @@ -534,8 +602,8 @@ class Suite extends Test { super(options); try { - const context = { signal: this.signal }; - this.buildSuite = this.runInAsyncScope(this.fn, context, [context]); + const { ctx, args } = this.getRunArgs(); + this.buildSuite = this.runInAsyncScope(this.fn, ctx, args); } catch (err) { this.fail(new ERR_TEST_FAILURE(err, kTestCodeFailure)); } @@ -543,6 +611,10 @@ class Suite extends Test { this.buildPhaseFinished = true; } + getRunArgs() { + return { ctx: { signal: this.signal, name: this.name }, args: [] }; + } + start() { return this.run(); } @@ -562,11 +634,16 @@ class Suite extends Test { return; } + + const hookArgs = this.getRunArgs(); + await this[kRunHook]('before', hookArgs); const stopPromise = stopTest(this.timeout, this.signal); const subtests = this.skipped || this.error ? [] : this.subtests; const promise = SafePromiseAll(subtests, (subtests) => subtests.start()); await SafePromiseRace([promise, stopPromise]); + await this[kRunHook]('after', hookArgs); + this.pass(); this.postRun(); } diff --git a/lib/test.js b/lib/test.js index 7ebc852092b93b9..a365eef6d45f91a 100644 --- a/lib/test.js +++ b/lib/test.js @@ -1,5 +1,5 @@ 'use strict'; -const { test, describe, it } = require('internal/test_runner/harness'); +const { test, describe, it, before, after, beforeEach, afterEach } = require('internal/test_runner/harness'); const { emitExperimentalWarning } = require('internal/util'); emitExperimentalWarning('The test runner'); @@ -8,3 +8,7 @@ module.exports = test; module.exports.test = test; module.exports.describe = describe; module.exports.it = it; +module.exports.before = before; +module.exports.after = after; +module.exports.beforeEach = beforeEach; +module.exports.afterEach = afterEach; diff --git a/test/message/test_runner_describe_it.js b/test/message/test_runner_describe_it.js index c272fb38a749f6c..8da36b447d77229 100644 --- a/test/message/test_runner_describe_it.js +++ b/test/message/test_runner_describe_it.js @@ -225,15 +225,15 @@ it('callback fail', (done) => { }); it('sync t is this in test', function() { - assert.deepStrictEqual(this, { signal: this.signal }); + assert.deepStrictEqual(this, { signal: this.signal, name: this.name }); }); it('async t is this in test', async function() { - assert.deepStrictEqual(this, { signal: this.signal }); + assert.deepStrictEqual(this, { signal: this.signal, name: this.name }); }); it('callback t is this in test', function(done) { - assert.deepStrictEqual(this, { signal: this.signal }); + assert.deepStrictEqual(this, { signal: this.signal, name: this.name }); done(); }); diff --git a/test/parallel/test-runner-hooks.js b/test/parallel/test-runner-hooks.js new file mode 100644 index 000000000000000..6b1988012b24585 --- /dev/null +++ b/test/parallel/test-runner-hooks.js @@ -0,0 +1,52 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const { describe, it, before, after, beforeEach, afterEach, test } = require('node:test'); + +const testArr = []; +const afterTest = common.mustCall(() => { + assert.deepStrictEqual(testArr, [ + 'beforeEach 1', '1', 'afterEach 1', + 'beforeEach 2', '2', 'afterEach 2', + 'beforeEach 3', '3', 'afterEach 3', + ]); +}); +test('test', async (t) => { + t.beforeEach((t) => testArr.push('beforeEach ' + t.name)); + t.afterEach((t) => testArr.push('afterEach ' + t.name)); + + await t.test('1', () => testArr.push('1')); + await t.test('2', () => testArr.push('2')); + await t.test('3', () => testArr.push('3')); +}).then(afterTest); + + +const describeArr = []; +const afterDescribe = common.mustCall(() => { + assert.deepStrictEqual(describeArr, [ + 'before describe hooks', + 'beforeEach 1', '1', 'afterEach 1', + 'beforeEach 2', '2', 'afterEach 2', + 'beforeEach 3', '3', 'afterEach 3', + 'after describe hooks', + ]); +}); +describe('describe hooks', () => { + before(function() { + describeArr.push('before ' + this.name); + }); + after(function() { + describeArr.push('after ' + this.name); + afterDescribe(); + }); + beforeEach(function() { + describeArr.push('beforeEach ' + this.name); + }); + afterEach(function() { + describeArr.push('afterEach ' + this.name); + }); + + it('1', () => describeArr.push('1')); + it('2', () => describeArr.push('2')); + it('3', () => describeArr.push('3')); +});