Skip to content

Commit cb5f671

Browse files
authored
test_runner: add global setup and teardown functionality
PR-URL: #57438 Reviewed-By: Matteo Collina <[email protected]> Reviewed-By: Colin Ihrig <[email protected]> Reviewed-By: Marco Ippolito <[email protected]> Reviewed-By: Chemi Atlow <[email protected]>
1 parent e800f00 commit cb5f671

26 files changed

+1270
-10
lines changed

‎doc/api/cli.md‎

Lines changed: 15 additions & 0 deletions
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‎

Lines changed: 54 additions & 0 deletions
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+
asyncfunctionglobalSetup(){
422+
// Setup shared resources, state, or environment
423+
console.log('Global setup executed');
424+
// Run servers, create files, prepare databases, etc.
425+
}
426+
427+
asyncfunctionglobalTeardown(){
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+
exportasyncfunctionglobalSetup(){
439+
// Setup shared resources, state, or environment
440+
console.log('Global setup executed');
441+
// Run servers, create files, prepare databases, etc.
442+
}
443+
444+
exportasyncfunctionglobalTeardown(){
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‎

Lines changed: 3 additions & 0 deletions
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‎

Lines changed: 3 additions & 0 deletions
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+
.ItFl-test-global-setup
468+
Specifies a module containing global setup and teardown functions for the test runner.
469+
.
467470
.ItFl-test-isolationNs=NsArmode
468471
Configures the type of test isolation used in the test runner.
469472
.

‎lib/internal/test_runner/harness.js‎

Lines changed: 28 additions & 4 deletions
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-
constresolvedPromise=PromiseResolve();
3434
consttestResources=newSafeMap();
3535
letglobalRoot;
36+
letglobalSetupExecuted=false;
3637

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

3940
functioncreateTestTree(rootTestOptions,globalOptions){
4041
constbuildPhaseDeferred=PromiseWithResolvers();
4142
constisFilteringByName=globalOptions.testNamePatterns||
42-
globalOptions.testSkipPatterns;
43+
globalOptions.testSkipPatterns;
4344
constisFilteringByOnly=(globalOptions.isolation==='process'||process.env.NODE_TEST_CONTEXT) ?
4445
globalOptions.only : true;
4546
constharness={
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+
asyncrunBootstrap(){
75+
if(globalSetupExecuted){
76+
returnPromiseResolve();
77+
}
78+
globalSetupExecuted=true;
79+
constglobalSetupFunctions=awaitsetupGlobalSetupTeardownFunctions(
80+
globalOptions.globalSetupPath,
81+
globalOptions.cwd,
82+
);
83+
harness.globalTeardownFunction=globalSetupFunctions.globalTeardownFunction;
84+
if(typeofglobalSetupFunctions.globalSetupFunction==='function'){
85+
returnglobalSetupFunctions.globalSetupFunction();
86+
}
87+
returnPromiseResolve();
88+
},
7489
asyncwaitForBuildPhase(){
7590
if(harness.buildSuites.length>0){
7691
awaitSafePromiseAllReturnVoid(harness.buildSuites);
@@ -81,6 +96,7 @@ function createTestTree(rootTestOptions, globalOptions){
8196
};
8297

8398
harness.resetCounters();
99+
harness.bootstrapPromise=harness.runBootstrap();
84100
globalRoot=newTest({
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+
awaitroot.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
returnglobalRoot;
284308
}

‎lib/internal/test_runner/runner.js‎

Lines changed: 14 additions & 2 deletions
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
constrootTestOptions={__proto__: null, concurrency, timeout, signal };
672678
constglobalOptions={
673679
__proto__: null,
@@ -682,6 +688,7 @@ function run(options = kEmptyObject){
682688
branchCoverage: branchCoverage,
683689
functionCoverage: functionCoverage,
684690
cwd,
691+
globalSetupPath,
685692
};
686693
constroot=createTestTree(rootTestOptions,globalOptions);
687694
lettestFiles=files??createTestFileList(globPatterns,cwd);
@@ -754,7 +761,9 @@ function run(options = kEmptyObject){
754761
constcascadedLoader=esmLoader.getOrInitializeCascadedLoader();
755762
lettopLevelTestCount=0;
756763

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

759768
constuserImports=getOptionValue('--import');
760769
for(leti=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+
returnroot.processPendingSubtests();
803812
};
804813
}
805814
}
806815

807816
construnChain=async()=>{
817+
if(root.harness?.bootstrapPromise){
818+
awaitroot.harness.bootstrapPromise;
819+
}
808820
if(typeofsetup==='function'){
809821
awaitsetup(root.reporter);
810822
}

‎lib/internal/test_runner/utils.js‎

Lines changed: 32 additions & 2 deletions
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
constcoverageColors={
4752
__proto__: null,
@@ -199,6 +204,7 @@ function parseCommandLine(){
199204
consttimeout=getOptionValue('--test-timeout')||Infinity;
200205
constisChildProcess=process.env.NODE_TEST_CONTEXT==='child';
201206
constisChildProcessV8=process.env.NODE_TEST_CONTEXT==='child-v8';
207+
letglobalSetupPath;
202208
letconcurrency;
203209
letcoverageExcludeGlobs;
204210
letcoverageIncludeGlobs;
@@ -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
returnreport;
598606
}
599607

608+
asyncfunctionsetupGlobalSetupTeardownFunctions(globalSetupPath,cwd){
609+
letglobalSetupFunction;
610+
letglobalTeardownFunction;
611+
if(globalSetupPath){
612+
validatePath(globalSetupPath,'options.globalSetupPath');
613+
constfileURL=pathToFileURL(resolve(cwd,globalSetupPath));
614+
constcascadedLoader=require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
615+
constglobalSetupModule=awaitcascadedLoader
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‎

Lines changed: 4 additions & 0 deletions
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‎

Lines changed: 1 addition & 0 deletions
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";
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use strict';
2+
3+
consttest=require('node:test');
4+
constassert=require('node:assert');
5+
constfs=require('node:fs');
6+
7+
test('Another test that verifies setup flag existance',(t)=>{
8+
constsetupFlagPath=process.env.SETUP_FLAG_PATH;
9+
assert.ok(fs.existsSync(setupFlagPath),'Setup flag file should exist');
10+
11+
constcontent=fs.readFileSync(setupFlagPath,'utf8');
12+
assert.strictEqual(content,'Setup was executed');
13+
});

0 commit comments

Comments
(0)