Skip to content

Commit

Permalink
test_runner: support running tests in process
Browse files Browse the repository at this point in the history
This commit introduces a new --experimental-test-isolation flag
that, when set to 'none', causes the test runner to execute all
tests in the same process. By default, this is the main test
runner process, but if watch mode is enabled, it spawns a separate
process that runs all of the tests.

The default value of the new flag is 'process', which uses the
existing behavior of running each test file in its own child
process.

It is worth noting that when the isolation mode is 'none', globals
and all other top level logic (such as top level before() and after()
hooks) is shared among all files.

Co-authored-by: Moshe Atlow <moshe@atlow.co.il>
  • Loading branch information
cjihrig and MoLow committed Jul 19, 2024
1 parent 3d019ce commit 5282a6e
Show file tree
Hide file tree
Showing 19 changed files with 556 additions and 229 deletions.
18 changes: 17 additions & 1 deletion doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,20 @@ generated as part of the test runner output. If no tests are run, a coverage
report is not generated. See the documentation on
[collecting code coverage from tests][] for more details.

### `--experimental-test-isolation=mode`

<!-- YAML
added: REPLACEME
-->

> Stability: 1.0 - Early development
Configures the type of test isolation used in the test runner. When `mode` is
`'process'`, each test file is run in a separate child process. When `mode` is
`'none'`, all test files run in the same process as the test runner. The default
isolation mode is `'process'`. This flag is ignored if the `--test` flag is not
present.

### `--experimental-test-module-mocks`

<!-- YAML
Expand Down Expand Up @@ -2196,7 +2210,9 @@ added:
-->

The maximum number of test files that the test runner CLI will execute
concurrently. The default value is `os.availableParallelism() - 1`.
concurrently. The default concurrency depends on the test isolation mode in use.
If `--experimental-test-isolation` is set to `'none'`, concurrency defaults to
one. Otherwise, the default value is `os.availableParallelism() - 1`.

### `--test-force-exit`

Expand Down
12 changes: 10 additions & 2 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,9 @@ added:
- v18.9.0
- v16.19.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/53927
description: Added the `isolation` option.
- version:
- v22.0.0
- v20.14.0
Expand Down Expand Up @@ -1268,8 +1271,13 @@ changes:
* `inspectPort` {number|Function} Sets inspector port of test child process.
This can be a number, or a function that takes no arguments and returns a
number. If a nullish value is provided, each process gets its own port,
incremented from the primary's `process.debugPort`.
**Default:** `undefined`.
incremented from the primary's `process.debugPort`. This option is ignored
if the `isolation` option is set to `'none'` as no child processes are
spawned. **Default:** `undefined`.
* `isolation` {string} Configures the type of test isolation. If set to
`'process'`, each test file is run in a separate child process. If set to
`'none'`, all test files run in the current process. The default isolation
mode is `'process'`.
* `only`: {boolean} If truthy, the test context will only run tests that
have the `only` option set
* `setup` {Function} A function that accepts the `TestsStream` instance
Expand Down
3 changes: 3 additions & 0 deletions doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ Enable the experimental node:sqlite module.
.It Fl -experimental-test-coverage
Enable code coverage in the test runner.
.
.It Fl -experimental-test-isolation Ns = Ns Ar mode
Configures the type of test isolation used in the test runner.
.
.It Fl -experimental-test-module-mocks
Enable module mocking in the test runner.
.
Expand Down
6 changes: 4 additions & 2 deletions lib/internal/main/test_runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ prepareMainThreadExecution(false);
markBootstrapComplete();

const {
isolation,
perFileTimeout,
runnerConcurrency,
shard,
Expand All @@ -38,10 +39,11 @@ if (isUsingInspector()) {
const options = {
concurrency,
inspectPort,
watch: watchMode,
isolation,
setup: setupTestReporters,
timeout: perFileTimeout,
shard,
timeout: perFileTimeout,
watch: watchMode,
};
debug('test runner configuration:', options);
run(options).on('test:fail', (data) => {
Expand Down
47 changes: 25 additions & 22 deletions lib/internal/test_runner/harness.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ function setup(root) {
};
},
counters: null,
shouldColorizeTestFiles: false,
shouldColorizeTestFiles: shouldColorizeTestFiles(globalOptions.destinations),
teardown: exitHandler,
snapshotManager: null,
};
Expand All @@ -218,48 +218,50 @@ function setup(root) {
}

let globalRoot;
let reportersSetup;
function getGlobalRoot() {
let asyncBootstrap;
function lazyBootstrapRoot() {
if (!globalRoot) {
globalRoot = createTestTree({ __proto__: null, entryFile: process.argv?.[1] });
globalRoot.reporter.on('test:fail', (data) => {
if (data.todo === undefined || data.todo === false) {
process.exitCode = kGenericUserError;
}
});
reportersSetup = setupTestReporters(globalRoot.reporter);
globalRoot.harness.shouldColorizeTestFiles ||= shouldColorizeTestFiles(globalRoot);
asyncBootstrap = setupTestReporters(globalRoot.reporter);
}
return globalRoot;
}

function setRootTest(root) {
globalRoot = root;
}

async function startSubtest(subtest) {
if (reportersSetup) {
if (asyncBootstrap) {
// Only incur the overhead of awaiting the Promise once.
await reportersSetup;
reportersSetup = undefined;
}

const root = getGlobalRoot();
if (!root.harness.bootstrapComplete) {
root.harness.bootstrapComplete = true;
queueMicrotask(() => {
root.harness.allowTestsToRun = true;
root.processPendingSubtests();
});
await asyncBootstrap;
asyncBootstrap = undefined;
if (!subtest.root.harness.bootstrapComplete) {
subtest.root.harness.bootstrapComplete = true;
queueMicrotask(() => {
subtest.root.harness.allowTestsToRun = true;
subtest.root.processPendingSubtests();
});
}
}

await subtest.start();
}

function runInParentContext(Factory) {
function run(name, options, fn, overrides) {
const parent = testResources.get(executionAsyncId()) || getGlobalRoot();
const parent = testResources.get(executionAsyncId()) || lazyBootstrapRoot();
const subtest = parent.createSubtest(Factory, name, options, fn, overrides);
if (!(parent instanceof Suite)) {
return startSubtest(subtest);
if (parent instanceof Suite) {
return PromiseResolve();
}
return PromiseResolve();

return startSubtest(subtest);
}

const test = (name, options, fn) => {
Expand All @@ -286,7 +288,7 @@ function runInParentContext(Factory) {

function hook(hook) {
return (fn, options) => {
const parent = testResources.get(executionAsyncId()) || getGlobalRoot();
const parent = testResources.get(executionAsyncId()) || lazyBootstrapRoot();
parent.createHook(hook, fn, {
__proto__: null,
...options,
Expand All @@ -305,4 +307,5 @@ module.exports = {
after: hook('after'),
beforeEach: hook('beforeEach'),
afterEach: hook('afterEach'),
setRootTest,
};
Loading

0 comments on commit 5282a6e

Please sign in to comment.