Skip to content

Commit

Permalink
Core: Add QUnit.hooks to globally add beforeEach and afterEach
Browse files Browse the repository at this point in the history
Ref #1475.
  • Loading branch information
Krinkle committed Feb 14, 2022
1 parent 23b2efd commit 17cfe79
Show file tree
Hide file tree
Showing 10 changed files with 294 additions and 2 deletions.
37 changes: 37 additions & 0 deletions 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 )`<br>
`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();
});
```
4 changes: 2 additions & 2 deletions docs/QUnit/module.md
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/core.js
Expand Up @@ -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";
Expand Down Expand Up @@ -44,6 +45,7 @@ extend( QUnit, {
dump,
equiv,
reporters,
hooks,
is,
objectType,
on,
Expand Down
4 changes: 4 additions & 0 deletions src/core/config.js
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions 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" )
};
51 changes: 51 additions & 0 deletions src/test.js
Expand Up @@ -9,6 +9,7 @@ import Promise from "./promise";
import config from "./core/config";
import {
diff,
errorString,
extend,
generateHash,
hasOwn,
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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 + ": " +
Expand All @@ -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 );
Expand All @@ -281,6 +331,7 @@ Test.prototype = {

// Hooks are ignored on skipped tests
if ( !this.skip ) {
processGlobalhooks( this );
processHooks( this, this.module );
}

Expand Down
67 changes: 67 additions & 0 deletions test/cli/cli-main.js
Expand Up @@ -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";
Expand Down
11 changes: 11 additions & 0 deletions test/cli/fixtures/expected/tap-outputs.js
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions 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 );
} );

56 changes: 56 additions & 0 deletions 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 );
} );
} );
} );
} );

0 comments on commit 17cfe79

Please sign in to comment.