Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions doc/api/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -3720,6 +3720,39 @@ added: v25.0.0

Number of times the test has been attempted.

### `context.workerId`

<!-- YAML
added: REPLACEME
-->

* Type: {number|undefined}

The unique identifier of the worker running the current test file. This value is
derived from the `NODE_TEST_WORKER_ID` environment variable. When running tests
with `--test-isolation=process` (the default), each test file runs in a separate
child process and is assigned a worker ID from 1 to N, where N is the number of
concurrent workers. When running with `--test-isolation=none`, all tests run in
the same process and the worker ID is always 1. This value is `undefined` when
not running in a test context.

This property is useful for splitting resources (like database connections or
server ports) across concurrent test files:

```mjs
import { test } from 'node:test';
import { process } from 'node:process';

test('database operations', async (t) => {
// Worker ID is available via context
console.log(`Running in worker ${t.workerId}`);

// Or via environment variable (available at import time)
const workerId = process.env.NODE_TEST_WORKER_ID;
// Use workerId to allocate separate resources per worker
});
```

### `context.plan(count[,options])`

<!-- YAML
Expand Down
51 changes: 51 additions & 0 deletions lib/internal/test_runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {
ArrayPrototypeSlice,
ArrayPrototypeSome,
ArrayPrototypeSort,
MathMax,
ObjectAssign,
PromisePrototypeThen,
PromiseWithResolvers,
Expand All @@ -23,6 +24,7 @@ const {
SafePromiseAllReturnVoid,
SafePromiseAllSettledReturnVoid,
SafeSet,
String,
StringPrototypeIndexOf,
StringPrototypeSlice,
StringPrototypeStartsWith,
Expand All @@ -33,6 +35,7 @@ const {

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

let kResistStopPropagation;

// Worker ID pool management for concurrent test execution
class WorkerIdPool {
#nextId = 0;
#maxConcurrency;

constructor(maxConcurrency) {
this.#maxConcurrency = maxConcurrency;
}

acquire() {
const id = (this.#nextId++ % this.#maxConcurrency) + 1;
return id;
}
}

function createTestFileList(patterns, cwd) {
const hasUserSuppliedPattern = patterns != null;
if (!patterns || patterns.length === 0) {
Expand Down Expand Up @@ -404,6 +422,15 @@ function runTestFile(path, filesWatcher, opts) {
const args = getRunArgs(path, opts);
const stdio = ['pipe', 'pipe', 'pipe'];
const env = { __proto__: null, ...process.env, NODE_TEST_CONTEXT: 'child-v8' };

// Acquire a worker ID from the pool for process isolation mode
let workerId;
if (opts.workerIdPool) {
workerId = opts.workerIdPool.acquire();
env.NODE_TEST_WORKER_ID = String(workerId);
debug('Assigned worker ID %d to test file: %s', workerId, path);
}

if (watchMode) {
stdio.push('ipc');
env.WATCH_REPORT_DEPENDENCIES = '1';
Expand Down Expand Up @@ -747,6 +774,25 @@ function run(options = kEmptyObject) {
let postRun;
let filesWatcher;
let runFiles;

// Create worker ID pool for concurrent test execution.
// Use concurrency from globalOptions which has been processed by parseCommandLine().
const effectiveConcurrency = globalOptions.concurrency ?? concurrency;
let maxConcurrency = 1;
if (effectiveConcurrency === true) {
maxConcurrency = MathMax(availableParallelism() - 1, 1);
} else if (typeof effectiveConcurrency === 'number') {
maxConcurrency = effectiveConcurrency;
}
const workerIdPool = new WorkerIdPool(maxConcurrency);
debug(
'Created worker ID pool with max concurrency: %d, ' +
'effectiveConcurrency: %s, testFiles: %d',
maxConcurrency,
effectiveConcurrency,
testFiles.length,
);

const opts = {
__proto__: null,
root,
Expand All @@ -763,6 +809,7 @@ function run(options = kEmptyObject) {
argv,
execArgv,
rerunFailuresFilePath,
workerIdPool: isolation === 'process' ? workerIdPool : null,
};

if (isolation === 'process') {
Expand All @@ -789,6 +836,10 @@ function run(options = kEmptyObject) {
});
};
} else if (isolation === 'none') {
// For isolation=none, set worker ID to 1 in the current process
process.env.NODE_TEST_WORKER_ID = '1';
debug('Set NODE_TEST_WORKER_ID=1 for isolation=none');

if (watch) {
const absoluteTestFiles = ArrayPrototypeMap(testFiles, (file) => (isAbsolute(file) ? file : resolve(cwd, file)));
filesWatcher = watchFiles(absoluteTestFiles, opts);
Expand Down
5 changes: 5 additions & 0 deletions lib/internal/test_runner/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,11 @@ class TestContext {
return this.#test.attempt ?? 0;
}

get workerId() {
const envWorkerId = process.env.NODE_TEST_WORKER_ID;
return Number(envWorkerId) || undefined;
}

diagnostic(message) {
this.#test.diagnostic(message);
}
Expand Down
26 changes: 26 additions & 0 deletions test/fixtures/test-runner/worker-id/test-1.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'node:test';

test('worker ID is available as environment variable', (t) => {
const workerId = process.env.NODE_TEST_WORKER_ID;
if (workerId === undefined) {
throw new Error('NODE_TEST_WORKER_ID should be defined');
}

const id = Number(workerId);
if (isNaN(id) || id < 1) {
throw new Error(`Invalid worker ID: ${workerId}`);
}
});

test('worker ID is available via context', (t) => {
const workerId = t.workerId;
const envWorkerId = process.env.NODE_TEST_WORKER_ID;

if (workerId === undefined) {
throw new Error('context.workerId should be defined');
}

if (workerId !== Number(envWorkerId)) {
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
}
});
26 changes: 26 additions & 0 deletions test/fixtures/test-runner/worker-id/test-2.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'node:test';

test('worker ID is available as environment variable', (t) => {
const workerId = process.env.NODE_TEST_WORKER_ID;
if (workerId === undefined) {
throw new Error('NODE_TEST_WORKER_ID should be defined');
}

const id = Number(workerId);
if (isNaN(id) || id < 1) {
throw new Error(`Invalid worker ID: ${workerId}`);
}
});

test('worker ID is available via context', (t) => {
const workerId = t.workerId;
const envWorkerId = process.env.NODE_TEST_WORKER_ID;

if (workerId === undefined) {
throw new Error('context.workerId should be defined');
}

if (workerId !== Number(envWorkerId)) {
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
}
});
26 changes: 26 additions & 0 deletions test/fixtures/test-runner/worker-id/test-3.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { test } from 'node:test';

test('worker ID is available as environment variable', (t) => {
const workerId = process.env.NODE_TEST_WORKER_ID;
if (workerId === undefined) {
throw new Error('NODE_TEST_WORKER_ID should be defined');
}

const id = Number(workerId);
if (isNaN(id) || id < 1) {
throw new Error(`Invalid worker ID: ${workerId}`);
}
});

test('worker ID is available via context', (t) => {
const workerId = t.workerId;
const envWorkerId = process.env.NODE_TEST_WORKER_ID;

if (workerId === undefined) {
throw new Error('context.workerId should be defined');
}

if (workerId !== Number(envWorkerId)) {
throw new Error(`context.workerId (${workerId}) should match NODE_TEST_WORKER_ID (${envWorkerId})`);
}
});
141 changes: 141 additions & 0 deletions test/parallel/test-runner-worker-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
'use strict';
require('../common');
const fixtures = require('../common/fixtures');
const assert = require('node:assert');
const { spawnSync } = require('node:child_process');
const { test } = require('node:test');

test('NODE_TEST_WORKER_ID is set for concurrent test files', async () => {
const args = [
'--test',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-3.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID is set with explicit concurrency', async () => {
const args = [
'--test',
'--test-concurrency=2',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID is 1 with concurrency=1', async () => {
const args = ['--test', '--test-concurrency=1', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID with explicit isolation=process', async () => {
const args = [
'--test',
'--test-isolation=process',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('NODE_TEST_WORKER_ID is 1 with isolation=none', async () => {
const args = [
'--test',
'--test-isolation=none',
fixtures.path('test-runner', 'worker-id', 'test-1.mjs'),
fixtures.path('test-runner', 'worker-id', 'test-2.mjs'),
];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('context.workerId matches NODE_TEST_WORKER_ID', async () => {
const args = ['--test', fixtures.path('test-runner', 'worker-id', 'test-1.mjs')];
const result = spawnSync(process.execPath, args, {
cwd: fixtures.path(),
env: { ...process.env }
});

// The fixture tests already verify that context.workerId matches the env var
assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);
});

test('worker IDs are reused when more tests than concurrency', async () => {
const tmpdir = require('../common/tmpdir');
const { writeFileSync } = require('node:fs');
tmpdir.refresh();

// Create 9 separate test files dynamically
const testFiles = [];
const usageFile = tmpdir.resolve('worker-usage.txt');
for (let i = 1; i <= 9; i++) {
const testFile = tmpdir.resolve(`reuse-test-${i}.mjs`);
writeFileSync(
testFile,
`import { test } from 'node:test';
import { appendFileSync } from 'node:fs';

test('track worker ${i}', () => {
const workerId = process.env.NODE_TEST_WORKER_ID;
const usageFile = process.env.WORKER_USAGE_FILE;
appendFileSync(usageFile, workerId + '\\n');
});
`,
);
testFiles.push(testFile);
}

const args = ['--test', '--test-concurrency=3', ...testFiles];
const result = spawnSync(process.execPath, args, {
env: { ...process.env, WORKER_USAGE_FILE: usageFile }
});

assert.strictEqual(result.status, 0, `Test failed: ${result.stderr.toString()}`);

// Read and analyze worker IDs used
const { readFileSync } = require('node:fs');
const workerIds = readFileSync(usageFile, 'utf8').trim().split('\n');

// Count occurrences of each worker ID
const workerCounts = {};
workerIds.forEach((id) => {
workerCounts[id] = (workerCounts[id] || 0) + 1;
});

const uniqueWorkers = Object.keys(workerCounts);
assert.strictEqual(
uniqueWorkers.length,
3,
`Should have exactly 3 unique worker IDs, got ${uniqueWorkers.length}: ${uniqueWorkers.join(', ')}`
);

Object.entries(workerCounts).forEach(([id, count]) => {
assert.strictEqual(count, 3, `Worker ID ${id} should be used 3 times, got ${count}`);
});
});
Loading