Skip to content

Commit 5cf226d

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 context.workerId Fixes: #55842
1 parent f6464c5 commit 5cf226d

File tree

7 files changed

+308
-0
lines changed

7 files changed

+308
-0
lines changed

doc/api/test.md

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

37783778
Number of times the test has been attempted.
37793779

3780+
### `context.workerId`
3781+
3782+
<!-- YAML
3783+
added: REPLACEME
3784+
-->
3785+
3786+
* Type: {number|undefined}
3787+
3788+
The unique identifier of the worker running the current test file. This value is
3789+
derived from the `NODE_TEST_WORKER_ID` environment variable. When running tests
3790+
with `--test-isolation=process` (the default), each test file runs in a separate
3791+
child process and is assigned a worker ID from 1 to N, where N is the number of
3792+
concurrent workers. When running with `--test-isolation=none`, all tests run in
3793+
the same process and the worker ID is always 1. This value is `undefined` when
3794+
not running in a test context.
3795+
3796+
This property is useful for splitting resources (like database connections or
3797+
server ports) across concurrent test files:
3798+
3799+
```mjs
3800+
import { test } from 'node:test';
3801+
import { process } from 'node:process';
3802+
3803+
test('database operations', async (t) => {
3804+
// Worker ID is available via context
3805+
console.log(`Running in worker ${t.workerId}`);
3806+
3807+
// Or via environment variable (available at import time)
3808+
const workerId = process.env.NODE_TEST_WORKER_ID;
3809+
// Use workerId to allocate separate resources per worker
3810+
});
3811+
```
3812+
37803813
### `context.plan(count[,options])`
37813814

37823815
<!-- YAML

lib/internal/test_runner/runner.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const {
1515
ArrayPrototypeSlice,
1616
ArrayPrototypeSome,
1717
ArrayPrototypeSort,
18+
MathMax,
1819
ObjectAssign,
1920
PromisePrototypeThen,
2021
PromiseWithResolvers,
@@ -23,6 +24,7 @@ const {
2324
SafePromiseAllReturnVoid,
2425
SafePromiseAllSettledReturnVoid,
2526
SafeSet,
27+
String,
2628
StringPrototypeIndexOf,
2729
StringPrototypeSlice,
2830
StringPrototypeStartsWith,
@@ -33,6 +35,7 @@ const {
3335

3436
const { spawn } = require('child_process');
3537
const { finished } = require('internal/streams/end-of-stream');
38+
const { availableParallelism } = require('os');
3639
const { resolve, sep, isAbsolute } = require('path');
3740
const { DefaultDeserializer, DefaultSerializer } = require('v8');
3841
const { getOptionValue, getOptionsAsFlagsFromBinding } = require('internal/options');
@@ -117,6 +120,21 @@ const kCanceledTests = new SafeSet()
117120

118121
let kResistStopPropagation;
119122

123+
// Worker ID pool management for concurrent test execution
124+
class WorkerIdPool {
125+
#nextId = 0;
126+
#maxConcurrency;
127+
128+
constructor(maxConcurrency) {
129+
this.#maxConcurrency = maxConcurrency;
130+
}
131+
132+
acquire() {
133+
const id = (this.#nextId++ % this.#maxConcurrency) + 1;
134+
return id;
135+
}
136+
}
137+
120138
function createTestFileList(patterns, cwd) {
121139
const hasUserSuppliedPattern = patterns != null;
122140
if (!patterns || patterns.length === 0) {
@@ -404,6 +422,15 @@ function runTestFile(path, filesWatcher, opts) {
404422
const args = getRunArgs(path, opts);
405423
const stdio = ['pipe', 'pipe', 'pipe'];
406424
const env = { __proto__: null, NODE_TEST_CONTEXT: 'child-v8', ...(opts.env || process.env) };
425+
426+
// Acquire a worker ID from the pool for process isolation mode
427+
let workerId;
428+
if (opts.workerIdPool) {
429+
workerId = opts.workerIdPool.acquire();
430+
env.NODE_TEST_WORKER_ID = String(workerId);
431+
debug('Assigned worker ID %d to test file: %s', workerId, path);
432+
}
433+
407434
if (watchMode) {
408435
stdio.push('ipc');
409436
env.WATCH_REPORT_DEPENDENCIES = '1';
@@ -769,6 +796,25 @@ function run(options = kEmptyObject) {
769796
let postRun;
770797
let filesWatcher;
771798
let runFiles;
799+
800+
// Create worker ID pool for concurrent test execution.
801+
// Use concurrency from globalOptions which has been processed by parseCommandLine().
802+
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
803+
let maxConcurrency = 1;
804+
if (effectiveConcurrency === true) {
805+
maxConcurrency = MathMax(availableParallelism() - 1, 1);
806+
} else if (typeof effectiveConcurrency === 'number') {
807+
maxConcurrency = effectiveConcurrency;
808+
}
809+
const workerIdPool = new WorkerIdPool(maxConcurrency);
810+
debug(
811+
'Created worker ID pool with max concurrency: %d, ' +
812+
'effectiveConcurrency: %s, testFiles: %d',
813+
maxConcurrency,
814+
effectiveConcurrency,
815+
testFiles.length,
816+
);
817+
772818
const opts = {
773819
__proto__: null,
774820
root,
@@ -786,6 +832,7 @@ function run(options = kEmptyObject) {
786832
execArgv,
787833
rerunFailuresFilePath,
788834
env,
835+
workerIdPool: isolation === 'process' ? workerIdPool : null,
789836
};
790837

791838
if (isolation === 'process') {
@@ -812,6 +859,10 @@ function run(options = kEmptyObject) {
812859
});
813860
};
814861
} else if (isolation === 'none') {
862+
// For isolation=none, set worker ID to 1 in the current process
863+
process.env.NODE_TEST_WORKER_ID = '1';
864+
debug('Set NODE_TEST_WORKER_ID=1 for isolation=none');
865+
815866
if (watch) {
816867
const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file)));
817868
filesWatcher = watchFiles(absoluteTestFiles, opts);

lib/internal/test_runner/test.js

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

288+
get workerId() {
289+
const envWorkerId = process.env.NODE_TEST_WORKER_ID;
290+
return Number(envWorkerId) || undefined;
291+
}
292+
288293
diagnostic(message) {
289294
this.#test.diagnostic(message);
290295
}
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+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use strict';
2+
require('../common');
3+
const fixtures = require('../common/fixtures');
4+
const assert = require('node:assert');
5+
const { spawnSync } = require('node:child_process');
6+
const { test } = require('node:test');
7+
8+
test('NODE_TEST_WORKER_ID is set for concurrent test files', async () => {
9+
const args = [
10+
'--test',
11+
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
12+
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
13+
fixtures.path('test-runner', 'worker-id', 'test-3.mjs'),
14+
];
15+
const result = spawnSync(process.execPath, args, {
16+
cwd: fixtures.path(),
17+
env: { ...process.env }
18+
});
19+
20+
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
21+
});
22+
23+
test('NODE_TEST_WORKER_ID is set with explicit concurrency', async () => {
24+
const args = [
25+
'--test',
26+
'--test-concurrency=2',
27+
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
28+
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
29+
];
30+
const result = spawnSync(process.execPath, args, {
31+
cwd: fixtures.path(),
32+
env: { ...process.env }
33+
});
34+
35+
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
36+
});
37+
38+
test('NODE_TEST_WORKER_ID is 1 with concurrency=1', async () => {
39+
const args = ['--test', '--test-concurrency=1', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
40+
const result = spawnSync(process.execPath, args, {
41+
cwd: fixtures.path(),
42+
env: { ...process.env }
43+
});
44+
45+
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
46+
});
47+
48+
test('NODE_TEST_WORKER_ID with explicit isolation=process', async () => {
49+
const args = [
50+
'--test',
51+
'--test-isolation=process',
52+
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
53+
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
54+
];
55+
const result = spawnSync(process.execPath, args, {
56+
cwd: fixtures.path(),
57+
env: { ...process.env }
58+
});
59+
60+
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
61+
});
62+
63+
test('NODE_TEST_WORKER_ID is 1 with isolation=none', async () => {
64+
const args = [
65+
'--test',
66+
'--test-isolation=none',
67+
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
68+
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
69+
];
70+
const result = spawnSync(process.execPath, args, {
71+
cwd: fixtures.path(),
72+
env: { ...process.env }
73+
});
74+
75+
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
76+
});
77+
78+
test('context.workerId matches NODE_TEST_WORKER_ID', async () => {
79+
const args = ['--test', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
80+
const result = spawnSync(process.execPath, args, {
81+
cwd: fixtures.path(),
82+
env: { ...process.env }
83+
});
84+
85+
// The fixture tests already verify that context.workerId matches the env var
86+
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
87+
});
88+
89+
test('worker IDs are reused when more tests than concurrency', async () => {
90+
const tmpdir = require('../common/tmpdir');
91+
const { writeFileSync } = require('node:fs');
92+
tmpdir.refresh();
93+
94+
// Create 9 separate test files dynamically
95+
const testFiles = [];
96+
const usageFile = tmpdir.resolve('worker-usage.txt');
97+
for (let i = 1; i <= 9; i++) {
98+
const testFile = tmpdir.resolve(`reuse-test-${i}.mjs`);
99+
writeFileSync(
100+
testFile,
101+
`import { test } from 'node:test';
102+
import { appendFileSync } from 'node:fs';
103+
104+
test('track worker ${i}', () => {
105+
const workerId = process.env.NODE_TEST_WORKER_ID;
106+
const usageFile = process.env.WORKER_USAGE_FILE;
107+
appendFileSync(usageFile, workerId + '\\n');
108+
});
109+
`,
110+
);
111+
testFiles.push(testFile);
112+
}
113+
114+
const args = ['--test', '--test-concurrency=3', ...testFiles];
115+
const result = spawnSync(process.execPath, args, {
116+
env: { ...process.env, WORKER_USAGE_FILE: usageFile }
117+
});
118+
119+
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
120+
121+
// Read and analyze worker IDs used
122+
const { readFileSync } = require('node:fs');
123+
const workerIds = readFileSync(usageFile, 'utf8').trim().split('\n');
124+
125+
// Count occurrences of each worker ID
126+
const workerCounts = {};
127+
workerIds.forEach((id) => {
128+
workerCounts[id] = (workerCounts[id] || 0) + 1;
129+
});
130+
131+
const uniqueWorkers = Object.keys(workerCounts);
132+
assert.strictEqual(
133+
uniqueWorkers.length,
134+
3,
135+
`Should have exactly 3 unique worker IDs, got ${uniqueWorkers.length}: ${uniqueWorkers.join(', ')}`
136+
);
137+
138+
Object.entries(workerCounts).forEach(([id, count]) => {
139+
assert.strictEqual(count, 3, `Worker ID ${id} should be used 3 times, got ${count}`);
140+
});
141+
});

0 commit comments

Comments
 (0)