Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core: Add QUnit.hooks to globally add beforeEach and afterEach #1670

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/QUnit/hooks.md
Original file line number Diff line number Diff line change
@@ -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();
});
```
63 changes: 42 additions & 21 deletions docs/QUnit/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,48 @@ Group related tests under a common label.
| parameter | description |
|-----------|-------------|
| `name` (string) | Label for this group of tests. |
| [`options`](#options-object) (object) | Set hook callbacks to run before or after test execution. |
| [`nested`](#nested-scope) (function) | A scope to create nested modules and/or set hooks functionally. |
| [`options`](#options-object) (object) | Set hook callbacks. |
| [`nested`](#nested-scope) (function) | A scope to create nested modules and/or add hooks functionally. |

All tests inside a module will be grouped under that module. The test names will be preceded by the module name in the test results. Tests can be added to a module using the [QUnit.test](./test.md) method.

You can use modules to organize, select, and filter tests to run. See [§ Organizing your tests](#organizing-your-tests).
All tests inside a module will be grouped under that module. Tests can be added to a module using the [QUnit.test](./test.md) method. Modules help organize, select, and filter tests to run. See [§ Organizing your tests](#organizing-your-tests).

Modules can be nested inside other modules. In the output, tests are generally prefixed by the names of all parent modules. E.g. "Grantparent > Parent > Child > my test". See [§ Nested module scope](#nested-module-scope).

The `QUnit.module.only()`, `QUnit.module.skip()`, and `QUnit.module.todo()` methods are aliases for `QUnit.module()` that apply the behaviour of [`QUnit.test.only()`](./test.only.md), [`QUnit.test.skip()`](./test.skip.md) or [`QUnit.test.todo()`](./test.todo.md) to all a module's tests at once.

### Hooks

You can use hooks to prepare fixtures, or run other setup and teardown logic. Hooks can run around individual tests, or around a whole module.

* `before`: Run a callback before the first test.
* `beforeEach`: Run a callback before each test.
* `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, 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 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

#### Hook callback

A hook callback may be an async function, and may return a Promise or any other then-able. QUnit will automatically wait for your hook's asynchronous work to finish before continuing to execute the tests.

Each hook has access to the same `assert` object, and test context via `this`, as the [QUnit.test](./test.md) that the hook is running for. Example: [§ Using the test context](#using-the-test-context).

| parameter | description |
|-----------|-------------|
| `assert` (object) | An [Assert](../assert/index.md) object. |

<p class="note" markdown="1">It is discouraged to dynamically create a new [QUnit.test](./test.md) from within a hook. In order to satisfy the requirement for the `after` hook to only run once and to be the last hook in a module, QUnit may associate dynamically defined tests with the parent module instead, or as global test. It is recommended to define any dynamic tests via [`QUnit.begin()`](../callbacks/QUnit.begin.md).</p>

### Options object

You can use the options object to set hook callbacks to prepare fixtures, or run other setup and
teardown logic. These hooks can run around individual tests, or around a whole module.
You can use the options object to add [hooks](#hooks).

| name | description |
|-----------|-------------|
Expand All @@ -43,27 +70,21 @@ teardown logic. These hooks can run around individual tests, or around a whole m
| `afterEach` (function) | Runs after each test. |
| `after` (function) | Runs after the last test. |

`QUnit.module()`'s hooks can automatically handle the asynchronous resolution of a Promise on your behalf if you return a `then`able Promise as the result of your callback function.

**Note**: If additional tests are defined after the module's queue has emptied, it will not run the `after` hook again.
Properties on the module options object are copied over to the test context object at the start of each test. Such properties can also be changed from the hook callbacks. See [§ Using the test context](#using-the-test-context).

Each [QUnit.test](./test.md) has its own test context object, accessible via its `this` variable. Properties on the module options object are copied over to the test context object at the start of each test. Such properties can also be changed from the hook callbacks. See [§ Using the test context](#using-the-test-context).
Example: [§ Declaring module options](#declaring-module-options).

### Nested scope

The nested callback can be used to create nested modules to run under a commmon label within the parent module.
Modules can be nested to group tests under under a commmon label within a parent module.

The scope is also given a `hooks` object which can be used to set hook options procedurally rather than
declaratively.
The module scope is given a `hooks` object which can be used to procedurally add [hooks](#hooks).

| name | description |
| parameter | description |
|-----------|-------------|
| `hooks` (object) | An object with methods for adding hook callbacks. |

QUnit will run tests on the parent module before those of nested ones, even if lexically declared earlier in the code. Additionally, any hook callbacks on a parent module will wrap the hooks on a nested module. In other words, `before` and `beforeEach` callbacks will form a [queue][] while the `afterEach` and `after` callbacks will form a [stack][].
| `hooks` (object) | An object for adding hooks. |

[queue]: https://en.wikipedia.org/wiki/Queue_%28abstract_data_type%29
[stack]: https://en.wikipedia.org/wiki/Stack_%28abstract_data_type%29
Example: [§ Nested module scope](#nested-module-scope).

## Changelog

Expand Down Expand Up @@ -122,7 +143,7 @@ test( "basic test example 4", assert => {
});
```

### Declaring hook options
### Declaring module options

```js
QUnit.module( "module A", {
Expand Down
2 changes: 1 addition & 1 deletion docs/QUnit/test.each.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Add tests using a data provider.
|-----------|-------------|
| `name` (string) | Title of unit being tested |
| `dataset` (array) | Array or object of data values passed to each test case |
| `callback` (function) | Function to close over assertions |
| `callback` (function) | Function that performs the test |

### Callback parameters

Expand Down
2 changes: 1 addition & 1 deletion docs/QUnit/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Define a test using `QUnit.test()`.
| parameter | description |
|-----------|-------------|
| `name` (string) | Title of unit being tested |
| `callback` (function) | Function to close over assertions |
| `callback` (function) | Function that performs the test |

### Callback parameters

Expand Down
2 changes: 1 addition & 1 deletion docs/QUnit/test.only.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Add a test that is exclusively run, preventing other tests from running unless t
| parameter | description |
|-----------|-------------|
| `name` (string) | Title of unit being tested |
| `callback` (function) | Function to close over assertions |
| `callback` (function) | Function that performs the test |

### Callback parameters

Expand Down
2 changes: 1 addition & 1 deletion docs/QUnit/test.skip.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Add a test that will be skipped during the run.
| parameter | description |
|-----------|-------------|
| `name` (string) | Title of unit being tested |
| `callback` (function) | Function to close over assertions |
| `callback` (function) | Function that performs the test |

Use this method to disable a [`QUnit.test()`](./test.md), as alternative to commenting out the test.

Expand Down
2 changes: 1 addition & 1 deletion docs/QUnit/test.todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Add a test which expects at least one failing assertion or exception during its
| parameter | description |
|-----------|-------------|
| `name` (string) | Title of unit being tested |
| `callback` (function) | Function to close over assertions |
| `callback` (function) | Function that performs the test |

### Callback parameters

Expand Down
7 changes: 3 additions & 4 deletions src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,25 @@ import { window, document, setTimeout } from "./globals";

import equiv from "./equiv";
import dump from "./dump";
import module from "./module";
import { runSuite, module } from "./module";
import Assert from "./assert";
import Logger from "./logger";
import Test, { test, pushFailure } from "./test";
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";
import ProcessingQueue from "./core/processing-queue";

import SuiteReport from "./reports/suite";

import { on, emit } from "./events";
import onWindowError from "./core/onerror";
import onUncaughtException from "./core/on-uncaught-exception";

const QUnit = {};
export const runSuite = new SuiteReport();

// The "currentModule" object would ideally be defined using the createModule()
// function. Since it isn't, add the missing suiteReport property to it now that
Expand All @@ -47,6 +45,7 @@ extend( QUnit, {
dump,
equiv,
reporters,
hooks,
is,
objectType,
on,
Expand Down
4 changes: 4 additions & 0 deletions src/core/config.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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" )
};
2 changes: 1 addition & 1 deletion src/core/on-uncaught-exception.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import config from "./config";
import { runSuite } from "../core";
import { runSuite } from "../module";
import { sourceFromStacktrace } from "./stacktrace";
import { errorString } from "./utilities";
import { emit } from "../events";
Expand Down
26 changes: 6 additions & 20 deletions src/core/processing-queue.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,12 @@
import config from "./config";
import {
extend,
generateHash,
now
} from "./utilities";
import {
runLoggingCallbacks
} from "./logging";
import { extend, generateHash, now } from "./utilities";
import { runLoggingCallbacks } from "./logging";

import Promise from "../promise";
import {
test
} from "../test";
import {
runSuite
} from "../core";
import {
emit
} from "../events";
import {
setTimeout
} from "../globals";
import { test } from "../test";
import { runSuite } from "../module";
import { emit } from "../events";
import { setTimeout } from "../globals";

let priorityCount = 0;
let unitSampler;
Expand Down
5 changes: 3 additions & 2 deletions src/module.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import config from "./core/config";
import SuiteReport from "./reports/suite";

import { extend, objectType, generateHash } from "./core/utilities";
import { runSuite } from "./core";

const moduleStack = [];

export const runSuite = new SuiteReport();

function isParentModuleInQueue() {
const modulesInQueue = config.modules
.filter( module => !module.ignored )
Expand Down Expand Up @@ -140,7 +141,7 @@ function processModule( name, options, executeNow, modifiers = {} ) {

let focused = false; // indicates that the "only" filter was used

export default function module( name, options, executeNow ) {
export function module( name, options, executeNow ) {

const ignored = focused && !isParentModuleInQueue();

Expand Down
51 changes: 51 additions & 0 deletions src/test.js
Original file line number Diff line number Diff line change
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