Skip to content

Commit 897df8b

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 ec49a09 commit 897df8b

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
@@ -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: 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, ...process.env, NODE_TEST_CONTEXT: 'child-v8' };
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';
@@ -747,6 +774,25 @@ function run(options = kEmptyObject) {
747774
let postRun;
748775
let filesWatcher;
749776
let runFiles;
777+
778+
// Create worker ID pool for concurrent test execution.
779+
// Use concurrency from globalOptions which has been processed by parseCommandLine().
780+
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
781+
let maxConcurrency = 1;
782+
if (effectiveConcurrency === true) {
783+
maxConcurrency = MathMax(availableParallelism() - 1, 1);
784+
} else if (typeof effectiveConcurrency === 'number') {
785+
maxConcurrency = effectiveConcurrency;
786+
}
787+
const workerIdPool = new WorkerIdPool(maxConcurrency);
788+
debug(
789+
'Created worker ID pool with max concurrency: %d, ' +
790+
'effectiveConcurrency: %s, testFiles: %d',
791+
maxConcurrency,
792+
effectiveConcurrency,
793+
testFiles.length,
794+
);
795+
750796
const opts = {
751797
__proto__: null,
752798
root,
@@ -763,6 +809,7 @@ function run(options = kEmptyObject) {
763809
argv,
764810
execArgv,
765811
rerunFailuresFilePath,
812+
workerIdPool: isolation === 'process' ? workerIdPool : null,
766813
};
767814

768815
if (isolation === 'process') {
@@ -789,6 +836,10 @@ function run(options = kEmptyObject) {
789836
});
790837
};
791838
} else if (isolation === 'none') {
839+
// For isolation=none, set worker ID to 1 in the current process
840+
process.env.NODE_TEST_WORKER_ID = '1';
841+
debug('Set NODE_TEST_WORKER_ID=1 for isolation=none');
842+
792843
if (watch) {
793844
const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file)));
794845
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 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+
});
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)