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

test_runner: export describe and it #43420

Closed
wants to merge 24 commits into from
95 changes: 93 additions & 2 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,42 @@ test('skip() method with message', (t) => {
});
```

## `describe`/`it` syntax

Running tests can also be done using `describe` to declare a suite
and `it` to declare a test.
A suite is used to organize and group related tests together.
`it` is an alias for `test`, except there is no test context passed,
since nesting is done using suites, as demonstrated in this example

```js
describe('A thing', () => {
it('should work', () => {
assert.strictEqual(1, 1);
});

it('should be ok', () => {
assert.strictEqual(2, 2);
});

describe('a nested thing', () => {
it('should work', () => {
assert.strictEqual(3, 3);
});
});
});
```

`describe` and `it` are imported from the `node:test` module

```mjs
import { describe, it } from 'node:test';
```

```cjs
const { describe, it } = require('node:test');
```

### `only` tests

If Node.js is started with the [`--test-only`][] command-line option, it is
Expand Down Expand Up @@ -303,7 +339,7 @@ added: v18.0.0
* `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string
is provided, that string is displayed in the test results as the reason why
the test is `TODO`. **Default:** `false`.
* `fn` {Function|AsyncFunction} The function under test. This first argument
* `fn` {Function|AsyncFunction} The function under test. The first argument
to this function is a [`TestContext`][] object. If the test uses callbacks,
the callback function is passed as the second argument. **Default:** A no-op
function.
Expand Down Expand Up @@ -335,6 +371,59 @@ test('top level test', async (t) => {
});
```

## `describe([name][, options][, fn])`

* `name` {string} The name of the suite, which is displayed when reporting test
results. **Default:** The `name` property of `fn`, or `'<anonymous>'` if `fn`
does not have a name.
* `options` {Object} Configuration options for the suite.
supports the same options as `test([name][, options][, fn])`
* `fn` {Function} The function under suite.
a synchronous function declaring all subtests and subsuites.
**Default:** A no-op function.
* Returns: `undefined`.

The `describe()` function imported from the `node:test` module. Each
invocation of this function results in the creation of a Subtest
and a test point in the TAP output.
After invocation of top level `describe` functions,
all top level tests and suites will execute

## `describe.skip([name][, options][, fn])`

Shorthand for skipping a suite, same as [`describe([name], { skip: true }[, fn])`][describe options].

## `describe.todo([name][, options][, fn])`

Shorthand for marking a suite as `TODO`, same as
[`describe([name], { todo: true }[, fn])`][describe options].

## `it([name][, options][, fn])`

* `name` {string} The name of the test, which is displayed when reporting test
results. **Default:** The `name` property of `fn`, or `'<anonymous>'` if `fn`
does not have a name.
* `options` {Object} Configuration options for the suite.
supports the same options as `test([name][, options][, fn])`.
* `fn` {Function|AsyncFunction} The function under test.
If the test uses callbacks, the callback function is passed as an argument.
**Default:** A no-op function.
* Returns: `undefined`.

The `it()` function is the value imported from the `node:test` module.
Each invocation of this function results in the creation of a test point in the
TAP output.

## `it.skip([name][, options][, fn])`

Shorthand for skipping a test,
same as [`it([name], { skip: true }[, fn])`][it options].

## `it.todo([name][, options][, fn])`

Shorthand for marking a test as `TODO`,
same as [`it([name], { todo: true }[, fn])`][it options].

## Class: `TestContext`

<!-- YAML
Expand Down Expand Up @@ -449,7 +538,7 @@ added: v18.0.0
* `todo` {boolean|string} If truthy, the test marked as `TODO`. If a string
is provided, that string is displayed in the test results as the reason why
the test is `TODO`. **Default:** `false`.
* `fn` {Function|AsyncFunction} The function under test. This first argument
* `fn` {Function|AsyncFunction} The function under test. The first argument
to this function is a [`TestContext`][] object. If the test uses callbacks,
the callback function is passed as the second argument. **Default:** A no-op
function.
Expand All @@ -475,4 +564,6 @@ test('top level test', async (t) => {
[`--test`]: cli.md#--test
[`TestContext`]: #class-testcontext
[`test()`]: #testname-options-fn
[describe options]: #describename-options-fn
[it options]: #testname-options-fn
[test runner execution model]: #test-runner-execution-model
2 changes: 1 addition & 1 deletion lib/internal/main/test_runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const {
ERR_TEST_FAILURE,
},
} = require('internal/errors');
const test = require('internal/test_runner/harness');
const { test } = require('internal/test_runner/harness');
const { kSubtestsFailed } = require('internal/test_runner/test');
const {
isSupportedFileType,
Expand Down
87 changes: 60 additions & 27 deletions lib/internal/test_runner/harness.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
'use strict';
const { FunctionPrototypeBind, SafeMap } = primordials;
const {
ArrayPrototypeForEach,
FunctionPrototypeBind,
SafeMap,
} = primordials;
const {
createHook,
executionAsyncId,
Expand All @@ -9,34 +13,43 @@ const {
ERR_TEST_FAILURE,
},
} = require('internal/errors');
const { Test } = require('internal/test_runner/test');
const { Test, ItTest, Suite } = require('internal/test_runner/test');


const testResources = new SafeMap();
const root = new Test({ __proto__: null, name: '<root>' });
let wasRootSetup = false;

function createProcessEventHandler(eventName, rootTest, testResources) {
function createProcessEventHandler(eventName, rootTest) {
return (err) => {
// Check if this error is coming from a test. If it is, fail the test.
const test = testResources.get(executionAsyncId());

if (test !== undefined) {
if (test.finished) {
// If the test is already finished, report this as a top level
// diagnostic since this is a malformed test.
const msg = `Warning: Test "${test.name}" generated asynchronous ` +
'activity after the test ended. This activity created the error ' +
`"${err}" and would have caused the test to fail, but instead ` +
`triggered an ${eventName} event.`;
if (!test) {
throw err;
}

rootTest.diagnostic(msg);
return;
}
if (test.finished) {
// If the test is already finished, report this as a top level
// diagnostic since this is a malformed test.
const msg = `Warning: Test "${test.name}" generated asynchronous ` +
'activity after the test ended. This activity created the error ' +
`"${err}" and would have caused the test to fail, but instead ` +
`triggered an ${eventName} event.`;

test.fail(new ERR_TEST_FAILURE(err, eventName));
test.postRun();
rootTest.diagnostic(msg);
return;
}

test.fail(new ERR_TEST_FAILURE(err, eventName));
test.postRun();
};
}

function setup(root) {
const testResources = new SafeMap();
if (wasRootSetup) {
return root;
}
const hook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
if (resource instanceof Test) {
Expand All @@ -58,9 +71,9 @@ function setup(root) {
hook.enable();

const exceptionHandler =
createProcessEventHandler('uncaughtException', root, testResources);
createProcessEventHandler('uncaughtException', root);
const rejectionHandler =
createProcessEventHandler('unhandledRejection', root, testResources);
createProcessEventHandler('unhandledRejection', root);

process.on('uncaughtException', exceptionHandler);
process.on('unhandledRejection', rejectionHandler);
Expand Down Expand Up @@ -113,19 +126,39 @@ function setup(root) {

root.reporter.pipe(process.stdout);
root.reporter.version();

wasRootSetup = true;
return root;
}

function test(name, options, fn) {
// If this is the first test encountered, bootstrap the test harness.
if (this.subtests.length === 0) {
setup(this);
const subtest = setup(root).createSubtest(Test, name, options, fn);
return subtest.start();
}

function runInParentContext(Factory) {
function run(name, options, fn, overrides) {
const parent = testResources.get(executionAsyncId()) || setup(root);
const subtest = parent.createSubtest(Factory, name, options, fn, overrides);
if (parent === root) {
subtest.start();
}
}

const subtest = this.createSubtest(name, options, fn);
const cb = (name, options, fn) => {
run(name, options, fn);
};

return subtest.start();
ArrayPrototypeForEach(['skip', 'todo'], (keyword) => {
cb[keyword] = (name, options, fn) => {
run(name, options, fn, { [keyword]: true });
};
});
return cb;
}

const root = new Test({ name: '<root>' });

module.exports = FunctionPrototypeBind(test, root);
module.exports = {
test: FunctionPrototypeBind(test, root),
describe: runInParentContext(Suite),
it: runInParentContext(ItTest),
};
Loading