Skip to content

Commit 863de78

Browse files
committed
test_runner: expose worker ID for concurrent test execution
This adds support for identifying which worker is running a test file when tests execute concurrently, similar to JEST_WORKER_ID in Jest, VITEST_POOL_ID in Vitest, and MOCHA_WORKER_ID in Mocha. When running with --test-isolation=process (default), each test file runs in a separate child process and receives a unique worker ID from 1 to N. When running with --test-isolation=none, all tests run in the same process and the worker ID is always 1. This enables users to allocate separate resources (databases, ports, etc.) for each test worker to avoid conflicts during concurrent execution. Changes: - Add WorkerIdPool class to manage worker ID allocation and reuse - Set NODE_TEST_WORKER_ID environment variable for child processes - Add context.workerId getter to TestContext class - Add tests for worker ID functionality - Add documentation for NODE_TEST_WORKER_ID and context.workerId Fixes: #55842
1 parent ec49a09 commit 863de78

File tree

8 files changed

+343
-0
lines changed

8 files changed

+343
-0
lines changed

doc/api/cli.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3830,6 +3830,26 @@ If `value` equals `'child'`, test reporter options will be overridden and test
38303830
output will be sent to stdout in the TAP format. If any other value is provided,
38313831
Node.js makes no guarantees about the reporter format used or its stability.
38323832

3833+
### `NODE_TEST_WORKER_ID=value`
3834+
3835+
<!-- YAML
3836+
added: REPLACEME
3837+
-->
3838+
3839+
The unique identifier (from `1` to `N`) of the worker running the current test
3840+
file when using the test runner with concurrent test execution. This variable
3841+
is automatically set by the test runner and should not be manually configured.
3842+
3843+
When running tests with `--test-isolation=process` (the default), each test
3844+
file runs in a separate child process and `value` will be a number from `1` to
3845+
`N`, where `N` is the number of concurrent workers. When running with
3846+
`--test-isolation=none`, all tests run in the same process and `value` is
3847+
always `'1'`.
3848+
3849+
This value can be used to allocate separate resources for each test worker,
3850+
such as unique database connections or server ports, to avoid conflicts during
3851+
concurrent test execution.
3852+
38333853
### `NODE_TLS_REJECT_UNAUTHORIZED=value`
38343854

38353855
If `value` equals `'0'`, certificate validation is disabled for TLS connections.

doc/api/test.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3720,6 +3720,39 @@ added: v25.0.0
37203720

37213721
Number of times the test has been attempted.
37223722

3723+
### `context.workerId`
3724+
3725+
<!-- YAML
3726+
added: REPLACEME
3727+
-->
3728+
3729+
* Type: {number|undefined}
3730+
3731+
The unique identifier of the worker running the current test file. This value is
3732+
derived from the `NODE_TEST_WORKER_ID` environment variable. When running tests
3733+
with `--test-isolation=process` (the default), each test file runs in a separate
3734+
child process and is assigned a worker ID from 1 to N, where N is the number of
3735+
concurrent workers. When running with `--test-isolation=none`, all tests run in
3736+
the same process and the worker ID is always 1. This value is `undefined` when
3737+
not running in a test context.
3738+
3739+
This property is useful for splitting resources (like database connections or
3740+
server ports) across concurrent test files:
3741+
3742+
```mjs
3743+
import { test } from 'node:test';
3744+
import { process } from 'node:process';
3745+
3746+
test('database operations', async (t) => {
3747+
// Worker ID is available via context
3748+
console.log(`Running in worker ${t.workerId}`);
3749+
3750+
// Or via environment variable (available at import time)
3751+
const workerId = process.env.NODE_TEST_WORKER_ID;
3752+
// Use workerId to allocate separate resources per worker
3753+
});
3754+
```
3755+
37233756
### `context.plan(count[,options])`
37243757

37253758
<!-- YAML

lib/internal/test_runner/runner.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const {
2323
SafePromiseAllReturnVoid,
2424
SafePromiseAllSettledReturnVoid,
2525
SafeSet,
26+
String,
2627
StringPrototypeIndexOf,
2728
StringPrototypeSlice,
2829
StringPrototypeStartsWith,
@@ -117,6 +118,31 @@ const kCanceledTests = new SafeSet()
117118

118119
let kResistStopPropagation;
119120

121+
// Worker ID pool management for concurrent test execution
122+
class WorkerIdPool {
123+
#availableIds = [];
124+
#nextId = 1;
125+
#maxConcurrency;
126+
127+
constructor(maxConcurrency) {
128+
this.#maxConcurrency = maxConcurrency || 1;
129+
}
130+
131+
acquire() {
132+
if (this.#availableIds.length > 0) {
133+
return ArrayPrototypeShift(this.#availableIds);
134+
}
135+
if (this.#nextId <= this.#maxConcurrency) {
136+
return this.#nextId++;
137+
}
138+
return 1; // Fallback if pool is exhausted
139+
}
140+
141+
release(id) {
142+
ArrayPrototypePush(this.#availableIds, id);
143+
}
144+
}
145+
120146
function createTestFileList(patterns, cwd) {
121147
const hasUserSuppliedPattern = patterns != null;
122148
if (!patterns || patterns.length === 0) {
@@ -404,6 +430,15 @@ function runTestFile(path, filesWatcher, opts) {
404430
const args = getRunArgs(path, opts);
405431
const stdio = ['pipe', 'pipe', 'pipe'];
406432
const env = { __proto__: null, ...process.env, NODE_TEST_CONTEXT: 'child-v8' };
433+
434+
// Acquire a worker ID from the pool for process isolation mode
435+
let workerId;
436+
if (opts.workerIdPool) {
437+
workerId = opts.workerIdPool.acquire();
438+
env.NODE_TEST_WORKER_ID = String(workerId);
439+
debug('Assigned worker ID %d to test file: %s', workerId, path);
440+
}
441+
407442
if (watchMode) {
408443
stdio.push('ipc');
409444
env.WATCH_REPORT_DEPENDENCIES = '1';
@@ -460,6 +495,11 @@ function runTestFile(path, filesWatcher, opts) {
460495
finished(child.stdout, { __proto__: null, signal: t.signal }),
461496
]);
462497

498+
// Release worker ID back to the pool
499+
if (workerId !== undefined && opts.workerIdPool) {
500+
opts.workerIdPool.release(workerId);
501+
}
502+
463503
if (watchMode) {
464504
filesWatcher.runningProcesses.delete(path);
465505
filesWatcher.runningSubtests.delete(path);
@@ -747,6 +787,26 @@ function run(options = kEmptyObject) {
747787
let postRun;
748788
let filesWatcher;
749789
let runFiles;
790+
791+
// Create worker ID pool for concurrent test execution.
792+
// Use concurrency from globalOptions which has been processed by parseCommandLine().
793+
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
794+
let maxConcurrency = 1;
795+
if (effectiveConcurrency === true) {
796+
// Default concurrency - use number of test files as max
797+
maxConcurrency = testFiles.length;
798+
} else if (typeof effectiveConcurrency === 'number') {
799+
maxConcurrency = effectiveConcurrency;
800+
}
801+
const workerIdPool = new WorkerIdPool(maxConcurrency);
802+
debug(
803+
'Created worker ID pool with max concurrency: %d, ' +
804+
'effectiveConcurrency: %s, testFiles: %d',
805+
maxConcurrency,
806+
effectiveConcurrency,
807+
testFiles.length,
808+
);
809+
750810
const opts = {
751811
__proto__: null,
752812
root,
@@ -763,6 +823,7 @@ function run(options = kEmptyObject) {
763823
argv,
764824
execArgv,
765825
rerunFailuresFilePath,
826+
workerIdPool: isolation === 'process' ? workerIdPool : null,
766827
};
767828

768829
if (isolation === 'process') {
@@ -789,6 +850,10 @@ function run(options = kEmptyObject) {
789850
});
790851
};
791852
} else if (isolation === 'none') {
853+
// For isolation=none, set worker ID to 1 in the current process
854+
process.env.NODE_TEST_WORKER_ID = '1';
855+
debug('Set NODE_TEST_WORKER_ID=1 for isolation=none');
856+
792857
if (watch) {
793858
const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file)));
794859
filesWatcher = watchFiles(absoluteTestFiles, opts);

lib/internal/test_runner/test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,11 @@ class TestContext {
284284
return this.#test.attempt ?? 0;
285285
}
286286

287+
get workerId() {
288+
const envWorkerId = process.env.NODE_TEST_WORKER_ID;
289+
return envWorkerId !== undefined ? Number(envWorkerId) : undefined;
290+
}
291+
287292
diagnostic(message) {
288293
this.#test.diagnostic(message);
289294
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test } from 'node:test';
2+
3+
test('worker ID is available as environment variable', (t) => {
4+
const workerId = process.env.NODE_TEST_WORKER_ID;
5+
if (workerId === undefined) {
6+
throw new Error('NODE_TEST_WORKER_ID should be defined');
7+
}
8+
9+
const id = Number(workerId);
10+
if (isNaN(id) || id < 1) {
11+
throw new Error(`Invalid worker ID: ${workerId}`);
12+
}
13+
});
14+
15+
test('worker ID is available via context', (t) => {
16+
const workerId = t.workerId;
17+
const envWorkerId = process.env.NODE_TEST_WORKER_ID;
18+
19+
if (workerId === undefined) {
20+
throw new Error('context.workerId should be defined');
21+
}
22+
23+
if (workerId !== Number(envWorkerId)) {
24+
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
25+
}
26+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test } from 'node:test';
2+
3+
test('worker ID is available as environment variable', (t) => {
4+
const workerId = process.env.NODE_TEST_WORKER_ID;
5+
if (workerId === undefined) {
6+
throw new Error('NODE_TEST_WORKER_ID should be defined');
7+
}
8+
9+
const id = Number(workerId);
10+
if (isNaN(id) || id < 1) {
11+
throw new Error(`Invalid worker ID: ${workerId}`);
12+
}
13+
});
14+
15+
test('worker ID is available via context', (t) => {
16+
const workerId = t.workerId;
17+
const envWorkerId = process.env.NODE_TEST_WORKER_ID;
18+
19+
if (workerId === undefined) {
20+
throw new Error('context.workerId should be defined');
21+
}
22+
23+
if (workerId !== Number(envWorkerId)) {
24+
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
25+
}
26+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { test } from 'node:test';
2+
3+
test('worker ID is available as environment variable', (t) => {
4+
const workerId = process.env.NODE_TEST_WORKER_ID;
5+
if (workerId === undefined) {
6+
throw new Error('NODE_TEST_WORKER_ID should be defined');
7+
}
8+
9+
const id = Number(workerId);
10+
if (isNaN(id) || id < 1) {
11+
throw new Error(`Invalid worker ID: ${workerId}`);
12+
}
13+
});
14+
15+
test('worker ID is available via context', (t) => {
16+
const workerId = t.workerId;
17+
const envWorkerId = process.env.NODE_TEST_WORKER_ID;
18+
19+
if (workerId === undefined) {
20+
throw new Error('context.workerId should be defined');
21+
}
22+
23+
if (workerId !== Number(envWorkerId)) {
24+
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
25+
}
26+
});

0 commit comments

Comments
 (0)