From 17cfe799a3514a180cd4f966bd9b83366ee40ea5 Mon Sep 17 00:00:00 2001 From: Timo Tijhof Date: Sat, 12 Feb 2022 17:44:52 +0000 Subject: [PATCH] Core: Add `QUnit.hooks` to globally add beforeEach and afterEach Ref https://github.com/qunitjs/qunit/issues/1475. --- docs/QUnit/hooks.md | 37 +++++++++++++ docs/QUnit/module.md | 4 +- src/core.js | 2 + src/core/config.js | 4 ++ src/core/hooks.js | 15 +++++ src/test.js | 51 +++++++++++++++++ test/cli/cli-main.js | 67 +++++++++++++++++++++++ test/cli/fixtures/expected/tap-outputs.js | 11 ++++ test/cli/fixtures/hooks-global-context.js | 49 +++++++++++++++++ test/cli/fixtures/hooks-global-order.js | 56 +++++++++++++++++++ 10 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 docs/QUnit/hooks.md create mode 100644 src/core/hooks.js create mode 100644 test/cli/fixtures/hooks-global-context.js create mode 100644 test/cli/fixtures/hooks-global-order.js diff --git a/docs/QUnit/hooks.md b/docs/QUnit/hooks.md new file mode 100644 index 000000000..22520e1d0 --- /dev/null +++ b/docs/QUnit/hooks.md @@ -0,0 +1,37 @@ +--- +layout: page-api +title: QUnit.hooks +excerpt: Add global callbacks to run before or after each test. +groups: + - main +version_added: "unreleased" +--- + +`QUnit.hooks.beforeEach( callback )`
+`QUnit.hooks.afterEach( callback )` + +Register a global callback to run before or after each test. + +| parameter | description | +|-----------|-------------| +| callback (function) | Callback to execute. Called with an [assert](../assert/index.md) argument. | + +This is the equivalent of applying a `QUnit.module()` hook to all modules and all tests, including global tests that are not associated with any module. + +Similar to module hooks, global hooks support async functions or returning a Promise, which will be waited for before QUnit continues executing tests. Each global hook also has access to the same `assert` object and test context as the [QUnit.test](./test.md) that the hook is running for. + +For more details about hooks, refer to [QUnit.module ยง Hooks](./module.md#hooks). + +## Examples + +```js +QUnit.hooks.beforeEach( function() { + this.app = new MyApp(); +}); + +QUnit.hooks.afterEach( async function( assert ) { + assert.deepEqual( [], await this.app.getErrors(), "MyApp errors" ); + + MyApp.reset(); +}); +``` diff --git a/docs/QUnit/module.md b/docs/QUnit/module.md index 8762eac10..9b0b7c402 100644 --- a/docs/QUnit/module.md +++ b/docs/QUnit/module.md @@ -38,11 +38,11 @@ You can use hooks to prepare fixtures, or run other setup and teardown logic. Ho * `afterEach`: Run a callback after each test. * `after`: Run a callback after the last test. -You can add hooks via the `hooks` parameter of a [scoped module](#nested-scope), or in the module [`options`](#options-object) object. +You can add hooks via the `hooks` parameter of a [scoped module](#nested-scope), or in the module [`options`](#options-object) object, or globally for all tests via [QUnit.hooks](./hooks.md). Hooks that are added to a module, will also apply to tests in any nested modules. -Hooks that run _before_ a test, are ordered from outer-most to inner-most, in the order that they are added. This means that a test will first run the hooks of parent modules, and then the hooks added to the immediate module the test is a part of. Hooks that run _after_ a test, are ordered from inner-most to outer-most, in the reverse order. In other words, `before` and `beforeEach` callbacks form a [queue][], while `afterEach` and `after` form a [stack][]. +Hooks that run _before_ a test, are ordered from outer-most to inner-most, in the order that they are added. This means that a test will first run any global beforeEach hooks, then the hooks of parent modules, and finally the hooks added to the immediate module the test is a part of. Hooks that run _after_ a test, are ordered from inner-most to outer-most, in the reverse order. In other words, `before` and `beforeEach` callbacks form a [queue][], while `afterEach` and `after` form a [stack][]. [queue]: https://en.wikipedia.org/wiki/Queue_%28abstract_data_type%29 [stack]: https://en.wikipedia.org/wiki/Stack_%28abstract_data_type%29 diff --git a/src/core.js b/src/core.js index 1ea9dfc7b..d95a361bd 100644 --- a/src/core.js +++ b/src/core.js @@ -10,6 +10,7 @@ import exportQUnit from "./export"; import reporters from "./reporters"; import config from "./core/config"; +import { hooks } from "./core/hooks"; import { extend, objectType, is, now } from "./core/utilities"; import { registerLoggingCallbacks, runLoggingCallbacks } from "./core/logging"; import { sourceFromStacktrace } from "./core/stacktrace"; @@ -44,6 +45,7 @@ extend( QUnit, { dump, equiv, reporters, + hooks, is, objectType, on, diff --git a/src/core/config.js b/src/core/config.js index 3dd7b82f0..92394a874 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -93,6 +93,10 @@ const config = { } }, + // Exposed to make resets easier + // Ref https://github.com/qunitjs/qunit/pull/1598 + globalHooks: {}, + callbacks: {}, // The storage module to use for reordering tests diff --git a/src/core/hooks.js b/src/core/hooks.js new file mode 100644 index 000000000..204b8aa0d --- /dev/null +++ b/src/core/hooks.js @@ -0,0 +1,15 @@ +import config from "./config"; + +function makeAddGlobalHook( hookName ) { + return function addGlobalHook( callback ) { + if ( !config.globalHooks[ hookName ] ) { + config.globalHooks[ hookName ] = []; + } + config.globalHooks[ hookName ].push( callback ); + }; +} + +export const hooks = { + beforeEach: makeAddGlobalHook( "beforeEach" ), + afterEach: makeAddGlobalHook( "afterEach" ) +}; diff --git a/src/test.js b/src/test.js index 574d85481..8f390ba5c 100644 --- a/src/test.js +++ b/src/test.js @@ -9,6 +9,7 @@ import Promise from "./promise"; import config from "./core/config"; import { diff, + errorString, extend, generateHash, hasOwn, @@ -224,6 +225,34 @@ Test.prototype = { checkPollution(); }, + queueGlobalHook( hook, hookName ) { + const runHook = () => { + config.current = this; + + let promise; + + if ( config.notrycatch ) { + promise = hook.call( this.testEnvironment, this.assert ); + + } else { + try { + promise = hook.call( this.testEnvironment, this.assert ); + } catch ( error ) { + this.pushFailure( + "Global " + hookName + " failed on " + this.testName + + ": " + errorString( error ), + extractStacktrace( error, 0 ) + ); + return; + } + } + + this.resolvePromise( promise, hookName ); + }; + + return runHook; + }, + queueHook( hook, hookName, hookOwner ) { const callHook = () => { const promise = hook.call( this.testEnvironment, this.assert ); @@ -253,6 +282,14 @@ Test.prototype = { return; } try { + + // This try-block includes the indirect call to resolvePromise, which shouldn't + // have to be inside try-catch. But, since we support any user-provided thenable + // object, the thenable might throw in some unexpected way. + // This subtle behaviour is undocumented. To avoid new failures in minor releases + // we will not change this until QUnit 3. + // TODO: In QUnit 3, reduce this try-block to just hook.call(), matching + // the simplicity of queueGlobalHook. callHook(); } catch ( error ) { this.pushFailure( hookName + " failed on " + this.testName + ": " + @@ -267,6 +304,19 @@ Test.prototype = { hooks( handler ) { const hooks = []; + function processGlobalhooks( test ) { + if ( + ( handler === "beforeEach" || handler === "afterEach" ) && + config.globalHooks[ handler ] + ) { + for ( let i = 0; i < config.globalHooks[ handler ].length; i++ ) { + hooks.push( + test.queueGlobalHook( config.globalHooks[ handler ][ i ], handler ) + ); + } + } + } + function processHooks( test, module ) { if ( module.parentModule ) { processHooks( test, module.parentModule ); @@ -281,6 +331,7 @@ Test.prototype = { // Hooks are ignored on skipped tests if ( !this.skip ) { + processGlobalhooks( this ); processHooks( this, this.module ); } diff --git a/test/cli/cli-main.js b/test/cli/cli-main.js index 25580edee..a3d0ad05c 100644 --- a/test/cli/cli-main.js +++ b/test/cli/cli-main.js @@ -358,6 +358,73 @@ CALLBACK: done`; assert.equal( execution.code, 0 ); } ); + QUnit.test( "global hooks order", async assert => { + const expected = ` +HOOK: A1 @ global beforeEach-1 +HOOK: A1 @ global beforeEach-2 +HOOK: A1 @ global afterEach-2 +HOOK: A1 @ global afterEach-1 +HOOK: B1 @ B before +HOOK: B1 @ global beforeEach-1 +HOOK: B1 @ global beforeEach-2 +HOOK: B1 @ B beforeEach +HOOK: B1 @ B afterEach +HOOK: B1 @ global afterEach-2 +HOOK: B1 @ global afterEach-1 +HOOK: B2 @ global beforeEach-1 +HOOK: B2 @ global beforeEach-2 +HOOK: B2 @ B beforeEach +HOOK: B2 @ B afterEach +HOOK: B2 @ global afterEach-2 +HOOK: B2 @ global afterEach-1 +HOOK: BC1 @ BC before +HOOK: BC1 @ global beforeEach-1 +HOOK: BC1 @ global beforeEach-2 +HOOK: BC1 @ B beforeEach +HOOK: BC1 @ BC beforeEach +HOOK: BC1 @ BC afterEach +HOOK: BC1 @ B afterEach +HOOK: BC1 @ global afterEach-2 +HOOK: BC1 @ global afterEach-1 +HOOK: BC2 @ global beforeEach-1 +HOOK: BC2 @ global beforeEach-2 +HOOK: BC2 @ B beforeEach +HOOK: BC2 @ BC beforeEach +HOOK: BC2 @ BC afterEach +HOOK: BC2 @ B afterEach +HOOK: BC2 @ global afterEach-2 +HOOK: BC2 @ global afterEach-1 +HOOK: BCD1 @ BCD before +HOOK: BCD1 @ global beforeEach-1 +HOOK: BCD1 @ global beforeEach-2 +HOOK: BCD1 @ B beforeEach +HOOK: BCD1 @ BC beforeEach +HOOK: BCD1 @ BCD beforeEach +HOOK: BCD1 @ BCD afterEach +HOOK: BCD1 @ BC afterEach +HOOK: BCD1 @ B afterEach +HOOK: BCD1 @ global afterEach-2 +HOOK: BCD1 @ global afterEach-1 +HOOK: BCD1 @ BCD after +HOOK: BCD1 @ BC after +HOOK: BCD1 @ B after`; + + const command = "qunit hooks-global-order.js"; + const execution = await execute( command ); + + assert.equal( execution.stderr, expected.trim() ); + assert.equal( execution.code, 0 ); + } ); + + QUnit.test( "global hooks context", async assert => { + const command = "qunit hooks-global-context.js"; + const execution = await execute( command ); + + assert.equal( execution.code, 0 ); + assert.equal( execution.stderr, "" ); + assert.equal( execution.stdout, expectedOutput[ command ] ); + } ); + if ( semver.gte( process.versions.node, "12.0.0" ) ) { QUnit.test( "run ESM test suite with import statement", async assert => { const command = "qunit ../../es2018/esm.mjs"; diff --git a/test/cli/fixtures/expected/tap-outputs.js b/test/cli/fixtures/expected/tap-outputs.js index b96d48546..ddfdd76a7 100644 --- a/test/cli/fixtures/expected/tap-outputs.js +++ b/test/cli/fixtures/expected/tap-outputs.js @@ -224,6 +224,17 @@ ok 2 timeout > second # todo 0 # fail 1`, + "qunit hooks-global-context.js": +`TAP version 13 +ok 1 A > A1 +ok 2 A > AB > AB1 +ok 3 B +1..3 +# pass 3 +# skip 0 +# todo 0 +# fail 0`, + "qunit zero-assertions.js": `TAP version 13 ok 1 Zero assertions > has a test diff --git a/test/cli/fixtures/hooks-global-context.js b/test/cli/fixtures/hooks-global-context.js new file mode 100644 index 000000000..b38d32f84 --- /dev/null +++ b/test/cli/fixtures/hooks-global-context.js @@ -0,0 +1,49 @@ +QUnit.hooks.beforeEach( function() { + this.x = 1; + this.fromGlobal = true; +} ); +QUnit.hooks.afterEach( function() { + this.x = 100; +} ); + +QUnit.module( "A", function( hooks ) { + hooks.beforeEach( function() { + this.x = 2; + this.fromModule = true; + } ); + hooks.afterEach( function() { + this.x = 20; + } ); + + QUnit.test( "A1", function( assert ) { + assert.equal( this.x, 2 ); + assert.strictEqual( this.fromGlobal, true ); + assert.strictEqual( this.fromModule, true ); + assert.strictEqual( this.fromNested, undefined ); + } ); + + QUnit.module( "AB", function( hooks ) { + hooks.beforeEach( function() { + this.x = 3; + this.fromNested = true; + } ); + hooks.afterEach( function() { + this.x = 30; + } ); + + QUnit.test( "AB1", function( assert ) { + assert.strictEqual( this.x, 3 ); + assert.strictEqual( this.fromGlobal, true ); + assert.strictEqual( this.fromModule, true ); + assert.strictEqual( this.fromNested, true ); + } ); + } ); +} ); + +QUnit.test( "B", function( assert ) { + assert.strictEqual( this.x, 1 ); + assert.strictEqual( this.fromGlobal, true ); + assert.strictEqual( this.fromModule, undefined ); + assert.strictEqual( this.fromNested, undefined ); +} ); + diff --git a/test/cli/fixtures/hooks-global-order.js b/test/cli/fixtures/hooks-global-order.js new file mode 100644 index 000000000..193573f12 --- /dev/null +++ b/test/cli/fixtures/hooks-global-order.js @@ -0,0 +1,56 @@ +function callback( label ) { + return function() { + console.warn( `HOOK: ${QUnit.config.current.testName} @ ${label}` ); + }; +} + +QUnit.hooks.beforeEach( callback( "global beforeEach-1" ) ); +QUnit.hooks.beforeEach( callback( "global beforeEach-2" ) ); +QUnit.hooks.afterEach( callback( "global afterEach-1" ) ); +QUnit.hooks.afterEach( callback( "global afterEach-2" ) ); + +QUnit.test( "A1", assert => { + assert.true( true ); +} ); + +QUnit.module( "B", hooks => { + hooks.before( callback( "B before" ) ); + hooks.beforeEach( callback( "B beforeEach" ) ); + hooks.afterEach( callback( "B afterEach" ) ); + hooks.after( callback( "B after" ) ); + + QUnit.test( "B1", assert => { + assert.true( true ); + } ); + + QUnit.test( "B2", assert => { + assert.true( true ); + } ); + + QUnit.module( "BC", hooks => { + hooks.before( callback( "BC before" ) ); + hooks.beforeEach( callback( "BC beforeEach" ) ); + hooks.afterEach( callback( "BC afterEach" ) ); + hooks.after( callback( "BC after" ) ); + + QUnit.test( "BC1", assert => { + assert.true( true ); + } ); + + QUnit.test( "BC2", assert => { + assert.true( true ); + } ); + + QUnit.module( "BCD", hooks => { + hooks.before( callback( "BCD before" ) ); + hooks.beforeEach( callback( "BCD beforeEach" ) ); + hooks.afterEach( callback( "BCD afterEach" ) ); + hooks.after( callback( "BCD after" ) ); + + QUnit.test( "BCD1", assert => { + assert.true( true ); + } ); + } ); + } ); +} ); +