Skip to content

Commit cb5f671

Browse files
authored
test_runner: add global setup and teardown functionality
PR-URL: #57438 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com> Reviewed-By: Chemi Atlow <chemi@atlow.co.il>
1 parent e800f00 commit cb5f671

26 files changed

+1270
-10
lines changed

doc/api/cli.md

+15
Original file line numberDiff line numberDiff line change
@@ -2452,6 +2452,19 @@ added:
24522452
Configures the test runner to exit the process once all known tests have
24532453
finished executing even if the event loop would otherwise remain active.
24542454

2455+
### `--test-global-setup=module`
2456+
2457+
<!-- YAML
2458+
added: REPLACEME
2459+
-->
2460+
2461+
> Stability: 1.0 - Early development
2462+
2463+
Specify a module that will be evaluated before all tests are executed and
2464+
can be used to setup global state or fixtures for tests.
2465+
2466+
See the documentation on [global setup and teardown][] for more details.
2467+
24552468
### `--test-isolation=mode`
24562469

24572470
<!-- YAML
@@ -3347,6 +3360,7 @@ one is included in the list below.
33473360
* `--test-coverage-functions`
33483361
* `--test-coverage-include`
33493362
* `--test-coverage-lines`
3363+
* `--test-global-setup`
33503364
* `--test-isolation`
33513365
* `--test-name-pattern`
33523366
* `--test-only`
@@ -3898,6 +3912,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
38983912
[emit_warning]: process.md#processemitwarningwarning-options
38993913
[environment_variables]: #environment-variables
39003914
[filtering tests by name]: test.md#filtering-tests-by-name
3915+
[global setup and teardown]: test.md#global-setup-and-teardown
39013916
[jitless]: https://v8.dev/blog/jitless
39023917
[libuv threadpool documentation]: https://docs.libuv.org/en/latest/threadpool.html
39033918
[module compile cache]: module.md#module-compile-cache

doc/api/test.md

+54
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,60 @@ their dependencies. When a change is detected, the test runner will
397397
rerun the tests affected by the change.
398398
The test runner will continue to run until the process is terminated.
399399

400+
## Global setup and teardown
401+
402+
<!-- YAML
403+
added: REPLACEME
404+
-->
405+
406+
> Stability: 1.0 - Early development
407+
408+
The test runner supports specifying a module that will be evaluated before all tests are executed and
409+
can be used to setup global state or fixtures for tests. This is useful for preparing resources or setting up
410+
shared state that is required by multiple tests.
411+
412+
This module can export any of the following:
413+
414+
* A `globalSetup` function which runs once before all tests start
415+
* A `globalTeardown` function which runs once after all tests complete
416+
417+
The module is specified using the `--test-global-setup` flag when running tests from the command line.
418+
419+
```cjs
420+
// setup-module.js
421+
async function globalSetup() {
422+
// Setup shared resources, state, or environment
423+
console.log('Global setup executed');
424+
// Run servers, create files, prepare databases, etc.
425+
}
426+
427+
async function globalTeardown() {
428+
// Clean up resources, state, or environment
429+
console.log('Global teardown executed');
430+
// Close servers, remove files, disconnect from databases, etc.
431+
}
432+
433+
module.exports = { globalSetup, globalTeardown };
434+
```
435+
436+
```mjs
437+
// setup-module.mjs
438+
export async function globalSetup() {
439+
// Setup shared resources, state, or environment
440+
console.log('Global setup executed');
441+
// Run servers, create files, prepare databases, etc.
442+
}
443+
444+
export async function globalTeardown() {
445+
// Clean up resources, state, or environment
446+
console.log('Global teardown executed');
447+
// Close servers, remove files, disconnect from databases, etc.
448+
}
449+
```
450+
451+
If the global setup function throws an error, no tests will be run and the process will exit with a non-zero exit code.
452+
The global teardown function will not be called in this case.
453+
400454
## Running tests from the command line
401455

402456
The Node.js test runner can be invoked from the command line by passing the

doc/node-config-schema.json

+3
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,9 @@
392392
"test-coverage-lines": {
393393
"type": "number"
394394
},
395+
"test-global-setup": {
396+
"type": "string"
397+
},
395398
"test-isolation": {
396399
"type": "string"
397400
},

doc/node.1

+3
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,9 @@ Require a minimum threshold for line coverage (0 - 100).
464464
Configures the test runner to exit the process once all known tests have
465465
finished executing even if the event loop would otherwise remain active.
466466
.
467+
.It Fl -test-global-setup
468+
Specifies a module containing global setup and teardown functions for the test runner.
469+
.
467470
.It Fl -test-isolation Ns = Ns Ar mode
468471
Configures the type of test isolation used in the test runner.
469472
.

lib/internal/test_runner/harness.js

+28-4
Original file line numberDiff line numberDiff line change
@@ -25,29 +25,29 @@ const {
2525
parseCommandLine,
2626
reporterScope,
2727
shouldColorizeTestFiles,
28+
setupGlobalSetupTeardownFunctions,
2829
} = require('internal/test_runner/utils');
2930
const { queueMicrotask } = require('internal/process/task_queues');
3031
const { TIMEOUT_MAX } = require('internal/timers');
3132
const { clearInterval, setInterval } = require('timers');
3233
const { bigint: hrtime } = process.hrtime;
33-
const resolvedPromise = PromiseResolve();
3434
const testResources = new SafeMap();
3535
let globalRoot;
36+
let globalSetupExecuted = false;
3637

3738
testResources.set(reporterScope.asyncId(), reporterScope);
3839

3940
function createTestTree(rootTestOptions, globalOptions) {
4041
const buildPhaseDeferred = PromiseWithResolvers();
4142
const isFilteringByName = globalOptions.testNamePatterns ||
42-
globalOptions.testSkipPatterns;
43+
globalOptions.testSkipPatterns;
4344
const isFilteringByOnly = (globalOptions.isolation === 'process' || process.env.NODE_TEST_CONTEXT) ?
4445
globalOptions.only : true;
4546
const harness = {
4647
__proto__: null,
4748
buildPromise: buildPhaseDeferred.promise,
4849
buildSuites: [],
4950
isWaitingForBuildPhase: false,
50-
bootstrapPromise: resolvedPromise,
5151
watching: false,
5252
config: globalOptions,
5353
coverage: null,
@@ -71,6 +71,21 @@ function createTestTree(rootTestOptions, globalOptions) {
7171
snapshotManager: null,
7272
isFilteringByName,
7373
isFilteringByOnly,
74+
async runBootstrap() {
75+
if (globalSetupExecuted) {
76+
return PromiseResolve();
77+
}
78+
globalSetupExecuted = true;
79+
const globalSetupFunctions = await setupGlobalSetupTeardownFunctions(
80+
globalOptions.globalSetupPath,
81+
globalOptions.cwd,
82+
);
83+
harness.globalTeardownFunction = globalSetupFunctions.globalTeardownFunction;
84+
if (typeof globalSetupFunctions.globalSetupFunction === 'function') {
85+
return globalSetupFunctions.globalSetupFunction();
86+
}
87+
return PromiseResolve();
88+
},
7489
async waitForBuildPhase() {
7590
if (harness.buildSuites.length > 0) {
7691
await SafePromiseAllReturnVoid(harness.buildSuites);
@@ -81,6 +96,7 @@ function createTestTree(rootTestOptions, globalOptions) {
8196
};
8297

8398
harness.resetCounters();
99+
harness.bootstrapPromise = harness.runBootstrap();
84100
globalRoot = new Test({
85101
__proto__: null,
86102
...rootTestOptions,
@@ -232,6 +248,11 @@ function setupProcessState(root, globalOptions) {
232248
'Promise resolution is still pending but the event loop has already resolved',
233249
kCancelledByParent));
234250

251+
if (root.harness.globalTeardownFunction) {
252+
await root.harness.globalTeardownFunction();
253+
root.harness.globalTeardownFunction = null;
254+
}
255+
235256
hook.disable();
236257
process.removeListener('uncaughtException', exceptionHandler);
237258
process.removeListener('unhandledRejection', rejectionHandler);
@@ -278,7 +299,10 @@ function lazyBootstrapRoot() {
278299
process.exitCode = kGenericUserError;
279300
}
280301
});
281-
globalRoot.harness.bootstrapPromise = globalOptions.setup(globalRoot.reporter);
302+
globalRoot.harness.bootstrapPromise = SafePromiseAllReturnVoid([
303+
globalRoot.harness.bootstrapPromise,
304+
globalOptions.setup(globalRoot.reporter),
305+
]);
282306
}
283307
return globalRoot;
284308
}

lib/internal/test_runner/runner.js

+14-2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const {
8787
} = require('internal/test_runner/utils');
8888
const { Glob } = require('internal/fs/glob');
8989
const { once } = require('events');
90+
const { validatePath } = require('internal/fs/utils');
9091
const {
9192
triggerUncaughtException,
9293
exitCodes: { kGenericUserError },
@@ -559,6 +560,7 @@ function run(options = kEmptyObject) {
559560
isolation = 'process',
560561
watch,
561562
setup,
563+
globalSetupPath,
562564
only,
563565
globPatterns,
564566
coverage = false,
@@ -668,6 +670,10 @@ function run(options = kEmptyObject) {
668670
validateStringArray(argv, 'options.argv');
669671
validateStringArray(execArgv, 'options.execArgv');
670672

673+
if (globalSetupPath != null) {
674+
validatePath(globalSetupPath, 'options.globalSetupPath');
675+
}
676+
671677
const rootTestOptions = { __proto__: null, concurrency, timeout, signal };
672678
const globalOptions = {
673679
__proto__: null,
@@ -682,6 +688,7 @@ function run(options = kEmptyObject) {
682688
branchCoverage: branchCoverage,
683689
functionCoverage: functionCoverage,
684690
cwd,
691+
globalSetupPath,
685692
};
686693
const root = createTestTree(rootTestOptions, globalOptions);
687694
let testFiles = files ?? createTestFileList(globPatterns, cwd);
@@ -754,7 +761,9 @@ function run(options = kEmptyObject) {
754761
const cascadedLoader = esmLoader.getOrInitializeCascadedLoader();
755762
let topLevelTestCount = 0;
756763

757-
root.harness.bootstrapPromise = promise;
764+
root.harness.bootstrapPromise = root.harness.bootstrapPromise ?
765+
SafePromiseAllReturnVoid([root.harness.bootstrapPromise, promise]) :
766+
promise;
758767

759768
const userImports = getOptionValue('--import');
760769
for (let i = 0; i < userImports.length; i++) {
@@ -799,12 +808,15 @@ function run(options = kEmptyObject) {
799808
debug('beginning test execution');
800809
root.entryFile = null;
801810
finishBootstrap();
802-
root.processPendingSubtests();
811+
return root.processPendingSubtests();
803812
};
804813
}
805814
}
806815

807816
const runChain = async () => {
817+
if (root.harness?.bootstrapPromise) {
818+
await root.harness.bootstrapPromise;
819+
}
808820
if (typeof setup === 'function') {
809821
await setup(root.reporter);
810822
}

lib/internal/test_runner/utils.js

+32-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const {
2727
} = primordials;
2828

2929
const { AsyncResource } = require('async_hooks');
30-
const { relative, sep } = require('path');
30+
const { relative, sep, resolve } = require('path');
3131
const { createWriteStream } = require('fs');
3232
const { pathToFileURL } = require('internal/url');
3333
const { getOptionValue } = require('internal/options');
@@ -41,7 +41,12 @@ const {
4141
kIsNodeError,
4242
} = require('internal/errors');
4343
const { compose } = require('stream');
44-
const { validateInteger } = require('internal/validators');
44+
const {
45+
validateInteger,
46+
validateFunction,
47+
} = require('internal/validators');
48+
const { validatePath } = require('internal/fs/utils');
49+
const { kEmptyObject } = require('internal/util');
4550

4651
const coverageColors = {
4752
__proto__: null,
@@ -199,6 +204,7 @@ function parseCommandLine() {
199204
const timeout = getOptionValue('--test-timeout') || Infinity;
200205
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
201206
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
207+
let globalSetupPath;
202208
let concurrency;
203209
let coverageExcludeGlobs;
204210
let coverageIncludeGlobs;
@@ -223,6 +229,7 @@ function parseCommandLine() {
223229
} else {
224230
destinations = getOptionValue('--test-reporter-destination');
225231
reporters = getOptionValue('--test-reporter');
232+
globalSetupPath = getOptionValue('--test-global-setup');
226233
if (reporters.length === 0 && destinations.length === 0) {
227234
ArrayPrototypePush(reporters, kDefaultReporter);
228235
}
@@ -328,6 +335,7 @@ function parseCommandLine() {
328335
only,
329336
reporters,
330337
setup,
338+
globalSetupPath,
331339
shard,
332340
sourceMaps,
333341
testNamePatterns,
@@ -597,6 +605,27 @@ function getCoverageReport(pad, summary, symbol, color, table) {
597605
return report;
598606
}
599607

608+
async function setupGlobalSetupTeardownFunctions(globalSetupPath, cwd) {
609+
let globalSetupFunction;
610+
let globalTeardownFunction;
611+
if (globalSetupPath) {
612+
validatePath(globalSetupPath, 'options.globalSetupPath');
613+
const fileURL = pathToFileURL(resolve(cwd, globalSetupPath));
614+
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
615+
const globalSetupModule = await cascadedLoader
616+
.import(fileURL, pathToFileURL(cwd + sep).href, kEmptyObject);
617+
if (globalSetupModule.globalSetup) {
618+
validateFunction(globalSetupModule.globalSetup, 'globalSetupModule.globalSetup');
619+
globalSetupFunction = globalSetupModule.globalSetup;
620+
}
621+
if (globalSetupModule.globalTeardown) {
622+
validateFunction(globalSetupModule.globalTeardown, 'globalSetupModule.globalTeardown');
623+
globalTeardownFunction = globalSetupModule.globalTeardown;
624+
}
625+
}
626+
return { __proto__: null, globalSetupFunction, globalTeardownFunction };
627+
}
628+
600629
module.exports = {
601630
convertStringToRegExp,
602631
countCompletedTest,
@@ -607,4 +636,5 @@ module.exports = {
607636
reporterScope,
608637
shouldColorizeTestFiles,
609638
getCoverageReport,
639+
setupGlobalSetupTeardownFunctions,
610640
};

src/node_options.cc

+4
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
761761
"exclude files from coverage report that match this glob pattern",
762762
&EnvironmentOptions::coverage_exclude_pattern,
763763
kAllowedInEnvvar);
764+
AddOption("--test-global-setup",
765+
"specifies the path to the global setup file",
766+
&EnvironmentOptions::test_global_setup_path,
767+
kAllowedInEnvvar);
764768
AddOption("--test-udp-no-try-send", "", // For testing only.
765769
&EnvironmentOptions::test_udp_no_try_send);
766770
AddOption("--throw-deprecation",

src/node_options.h

+1
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ class EnvironmentOptions : public Options {
197197
std::vector<std::string> test_name_pattern;
198198
std::vector<std::string> test_reporter;
199199
std::vector<std::string> test_reporter_destination;
200+
std::string test_global_setup_path;
200201
bool test_only = false;
201202
bool test_udp_no_try_send = false;
202203
std::string test_isolation = "process";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert');
5+
const fs = require('node:fs');
6+
7+
test('Another test that verifies setup flag existance', (t) => {
8+
const setupFlagPath = process.env.SETUP_FLAG_PATH;
9+
assert.ok(fs.existsSync(setupFlagPath), 'Setup flag file should exist');
10+
11+
const content = fs.readFileSync(setupFlagPath, 'utf8');
12+
assert.strictEqual(content, 'Setup was executed');
13+
});

0 commit comments

Comments
 (0)