Skip to content

Commit e6f9a59

Browse files
committed
test_runner: add --test-randomize functionality
1 parent ec88813 commit e6f9a59

File tree

11 files changed

+510
-3
lines changed

11 files changed

+510
-3
lines changed

doc/api/test.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,32 @@ prevent shell expansion, which can reduce portability across systems.
585585
node --test "**/*.test.js" "**/*.spec.js"
586586
```
587587

588+
### Randomizing test file execution order
589+
590+
The test runner can randomize the order of discovered test files to help detect
591+
order-dependent tests. Use `--test-randomize` to enable this mode.
592+
593+
```bash
594+
node --test --test-randomize
595+
```
596+
597+
When randomization is enabled, the test runner prints the seed used for the run
598+
as a diagnostic message:
599+
600+
```text
601+
Randomized test order seed: 12345
602+
```
603+
604+
Use `--test-random-seed=<number>` to replay the same order in a deterministic
605+
way. Supplying `--test-random-seed` also enables randomization, so
606+
`--test-randomize` is optional when a seed is provided:
607+
608+
```bash
609+
node --test --test-randomize --test-random-seed=12345
610+
```
611+
612+
`--test-randomize` and `--test-random-seed` are not supported with [`--watch`][] mode.
613+
588614
Matching files are executed as test files.
589615
More information on the test file execution can be found
590616
in the [test runner execution model][] section.
@@ -625,6 +651,8 @@ test runner functionality:
625651
* `--test-reporter` - Reporting is managed by the parent process
626652
* `--test-reporter-destination` - Output destinations are controlled by the parent
627653
* `--experimental-config-file` - Config file paths are managed by the parent
654+
* `--test-randomize` - File randomization is managed by the parent process
655+
* `--test-random-seed` - File randomization seed is managed by the parent process
628656

629657
All other Node.js options from command line arguments, environment variables,
630658
and configuration files are inherited by the child processes.
@@ -1531,6 +1559,13 @@ changes:
15311559
that specifies the index of the shard to run. This option is _required_.
15321560
* `total` {number} is a positive integer that specifies the total number
15331561
of shards to split the test files to. This option is _required_.
1562+
* `randomize` {boolean} Randomize the execution order of test files.
1563+
This option is not supported with `watch: true`.
1564+
**Default:** `false`.
1565+
* `randomSeed` {number} Seed used when randomizing test file order. If this
1566+
option is set, runs can replay the same randomized file order
1567+
deterministically, and setting this option also enables randomization.
1568+
**Default:** `undefined`.
15341569
* `rerunFailuresFilePath` {string} A file path where the test runner will
15351570
store the state of the tests to allow rerunning only the failed tests on a next run.
15361571
see \[Rerunning failed tests]\[] for more information.

doc/node-config-schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,14 @@
910910
"type": "boolean",
911911
"description": "run tests with 'only' option set"
912912
},
913+
"test-random-seed": {
914+
"type": "number",
915+
"description": "seed used to randomize test file execution order"
916+
},
917+
"test-randomize": {
918+
"type": "boolean",
919+
"description": "run test files in a random order"
920+
},
913921
"test-reporter": {
914922
"oneOf": [
915923
{

lib/internal/test_runner/runner.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const {
5656
validateObject,
5757
validateOneOf,
5858
validateInteger,
59+
validateUint32,
5960
validateString,
6061
validateStringArray,
6162
} = require('internal/validators');
@@ -81,10 +82,12 @@ const {
8182
const { FastBuffer } = require('internal/buffer');
8283

8384
const {
85+
createRandomSeed,
8486
convertStringToRegExp,
8587
countCompletedTest,
8688
kDefaultPattern,
8789
parseCommandLine,
90+
shuffleArrayWithSeed,
8891
} = require('internal/test_runner/utils');
8992
const { Glob } = require('internal/fs/glob');
9093
const { once } = require('events');
@@ -102,12 +105,14 @@ const kIsolatedProcessName = Symbol('kIsolatedProcessName');
102105
const kFilterArgs = [
103106
'--test',
104107
'--experimental-test-coverage',
108+
'--test-randomize',
105109
'--watch',
106110
'--experimental-default-config-file',
107111
];
108112
const kFilterArgValues = [
109113
'--test-reporter',
110114
'--test-reporter-destination',
115+
'--test-random-seed',
111116
'--experimental-config-file',
112117
];
113118
const kDiagnosticsFilterArgs = ['tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo', 'duration_ms'];
@@ -619,6 +624,8 @@ function run(options = kEmptyObject) {
619624
lineCoverage = 0,
620625
branchCoverage = 0,
621626
functionCoverage = 0,
627+
randomize: suppliedRandomize,
628+
randomSeed: suppliedRandomSeed,
622629
execArgv = [],
623630
argv = [],
624631
cwd = process.cwd(),
@@ -647,6 +654,37 @@ function run(options = kEmptyObject) {
647654
if (globPatterns != null) {
648655
validateArray(globPatterns, 'options.globPatterns');
649656
}
657+
if (suppliedRandomize != null) {
658+
validateBoolean(suppliedRandomize, 'options.randomize');
659+
}
660+
if (suppliedRandomSeed != null) {
661+
validateUint32(suppliedRandomSeed, 'options.randomSeed');
662+
}
663+
let randomize = suppliedRandomize;
664+
let randomSeed = suppliedRandomSeed;
665+
666+
if (randomSeed != null) {
667+
randomize = true;
668+
}
669+
if (watch) {
670+
if (randomSeed != null) {
671+
throw new ERR_INVALID_ARG_VALUE(
672+
'options.randomSeed',
673+
randomSeed,
674+
'is not supported with watch mode',
675+
);
676+
}
677+
if (randomize) {
678+
throw new ERR_INVALID_ARG_VALUE(
679+
'options.randomize',
680+
randomize,
681+
'is not supported with watch mode',
682+
);
683+
}
684+
}
685+
if (randomize) {
686+
randomSeed ??= createRandomSeed();
687+
}
650688

651689
validateString(cwd, 'options.cwd');
652690

@@ -757,10 +795,16 @@ function run(options = kEmptyObject) {
757795
cwd,
758796
globalSetupPath,
759797
};
798+
760799
const root = createTestTree(rootTestOptions, globalOptions);
761800
let testFiles = files ?? createTestFileList(globPatterns, cwd);
762801
const { isTestRunner } = globalOptions;
763802

803+
if (randomize) {
804+
testFiles = shuffleArrayWithSeed(testFiles, randomSeed);
805+
root.diagnostic(`Randomized test order seed: ${randomSeed}`);
806+
}
807+
764808
if (shard) {
765809
testFiles = ArrayPrototypeFilter(testFiles, (_, index) => index % shard.total === shard.index - 1);
766810
}
@@ -786,6 +830,8 @@ function run(options = kEmptyObject) {
786830
execArgv,
787831
rerunFailuresFilePath,
788832
env,
833+
randomize,
834+
randomSeed,
789835
};
790836

791837
if (isolation === 'process') {

lib/internal/test_runner/utils.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ const {
77
ArrayPrototypePop,
88
ArrayPrototypePush,
99
ArrayPrototypeReduce,
10+
ArrayPrototypeSlice,
1011
ArrayPrototypeSome,
1112
JSONParse,
1213
MathFloor,
14+
MathImul,
1315
MathMax,
1416
MathMin,
17+
MathRandom,
1518
NumberParseInt,
1619
NumberPrototypeToFixed,
1720
ObjectGetOwnPropertyDescriptor,
@@ -45,6 +48,7 @@ const { compose } = require('stream');
4548
const {
4649
validateInteger,
4750
validateFunction,
51+
validateUint32,
4852
} = require('internal/validators');
4953
const { validatePath } = require('internal/fs/utils');
5054
const { kEmptyObject } = require('internal/util');
@@ -58,6 +62,7 @@ const coverageColors = {
5862

5963
const kMultipleCallbackInvocations = 'multipleCallbackInvocations';
6064
const kRegExpPattern = /^\/(.*)\/([a-z]*)$/;
65+
const kMaxRandomSeed = 0xFFFF_FFFF;
6166

6267
const kPatterns = ['test', 'test/**/*', 'test-*', '*[._-]test'];
6368
const kFileExtensions = ['js', 'mjs', 'cjs'];
@@ -133,6 +138,54 @@ const kBuiltinReporters = new SafeMap([
133138
const kDefaultReporter = 'spec';
134139
const kDefaultDestination = 'stdout';
135140

141+
/**
142+
* Create a random uint32 seed.
143+
* @returns {number}
144+
*/
145+
function createRandomSeed() {
146+
return MathFloor(MathRandom() * (kMaxRandomSeed + 1));
147+
}
148+
149+
/**
150+
* Create a Mulberry32 pseudo-random number generator from a uint32 seed.
151+
* @param {number} seed
152+
* @returns {() => number}
153+
*/
154+
function createSeededGenerator(seed) {
155+
let state = seed >>> 0;
156+
return () => {
157+
state = (state + 0x6D2B79F5) | 0;
158+
let value = MathImul(state ^ state >>> 15, 1 | state);
159+
value ^= value + MathImul(value ^ value >>> 7, 61 | value);
160+
return ((value ^ value >>> 14) >>> 0) / 4_294_967_296;
161+
};
162+
}
163+
164+
/**
165+
* Return a deterministically shuffled copy of an array.
166+
* @template T
167+
* @param {T[]} values
168+
* @param {number} seed
169+
* @returns {T[]}
170+
*/
171+
function shuffleArrayWithSeed(values, seed) {
172+
if (values.length < 2) {
173+
return values;
174+
}
175+
176+
const randomized = ArrayPrototypeSlice(values);
177+
const random = createSeededGenerator(seed);
178+
179+
for (let i = randomized.length - 1; i > 0; i--) {
180+
const j = MathFloor(random() * (i + 1));
181+
const tmp = randomized[i];
182+
randomized[i] = randomized[j];
183+
randomized[j] = tmp;
184+
}
185+
186+
return randomized;
187+
}
188+
136189
function tryBuiltinReporter(name) {
137190
const builtinPath = kBuiltinReporters.get(name);
138191

@@ -217,6 +270,10 @@ function parseCommandLine() {
217270
const updateSnapshots = getOptionValue('--test-update-snapshots');
218271
const watch = getOptionValue('--watch');
219272
const timeout = getOptionValue('--test-timeout') || Infinity;
273+
let randomize = getOptionValue('--test-randomize');
274+
const hasRandomSeedOption = getOptionValue('[has_test_random_seed]');
275+
const randomSeedOption = getOptionValue('--test-random-seed');
276+
let randomSeed;
220277
const rerunFailuresFilePath = getOptionValue('--test-rerun-failures');
221278
const isChildProcess = process.env.NODE_TEST_CONTEXT === 'child';
222279
const isChildProcessV8 = process.env.NODE_TEST_CONTEXT === 'child-v8';
@@ -328,6 +385,12 @@ function parseCommandLine() {
328385
validatePath(rerunFailuresFilePath, '--test-rerun-failures');
329386
}
330387

388+
if (hasRandomSeedOption) {
389+
validateUint32(randomSeedOption, '--test-random-seed');
390+
randomSeed = randomSeedOption;
391+
randomize = true;
392+
}
393+
331394
const setup = reporterScope.bind(async (rootReporter) => {
332395
const reportersMap = await getReportersMap(reporters, destinations);
333396
for (let i = 0; i < reportersMap.length; i++) {
@@ -362,6 +425,8 @@ function parseCommandLine() {
362425
timeout,
363426
updateSnapshots,
364427
watch,
428+
randomize,
429+
randomSeed,
365430
rerunFailuresFilePath,
366431
};
367432

@@ -649,10 +714,14 @@ async function setupGlobalSetupTeardownFunctions(globalSetupPath, cwd) {
649714
module.exports = {
650715
convertStringToRegExp,
651716
countCompletedTest,
717+
createRandomSeed,
718+
createSeededGenerator,
652719
createDeferredCallback,
653720
isTestFailureError,
654721
kDefaultPattern,
722+
kMaxRandomSeed,
655723
parseCommandLine,
724+
shuffleArrayWithSeed,
656725
reporterScope,
657726
shouldColorizeTestFiles,
658727
getCoverageReport,

src/node_options.cc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,20 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
987987
&EnvironmentOptions::test_global_setup_path,
988988
kAllowedInEnvvar,
989989
OptionNamespaces::kTestRunnerNamespace);
990+
AddOption("--test-randomize",
991+
"run test files in a random order",
992+
&EnvironmentOptions::test_randomize,
993+
kDisallowedInEnvvar,
994+
false,
995+
OptionNamespaces::kTestRunnerNamespace);
996+
AddOption(
997+
"[has_test_random_seed]", "", &EnvironmentOptions::has_test_random_seed);
998+
AddOption("--test-random-seed",
999+
"seed used to randomize test file execution order",
1000+
&EnvironmentOptions::test_random_seed,
1001+
kDisallowedInEnvvar,
1002+
OptionNamespaces::kTestRunnerNamespace);
1003+
Implies("--test-random-seed", "[has_test_random_seed]");
9901004
AddOption("--test-rerun-failures",
9911005
"specifies the path to the rerun state file",
9921006
&EnvironmentOptions::test_rerun_failures_path,

src/node_options.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,9 @@ class EnvironmentOptions : public Options {
204204
std::string test_rerun_failures_path;
205205
std::vector<std::string> test_reporter_destination;
206206
std::string test_global_setup_path;
207+
bool test_randomize = false;
208+
bool has_test_random_seed = false;
209+
uint64_t test_random_seed = 0;
207210
bool test_only = false;
208211
bool test_udp_no_try_send = false;
209212
std::string test_isolation = "process";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict';
2+
require('../../../common');
3+
const fixtures = require('../../../common/fixtures');
4+
const spawn = require('node:child_process').spawn;
5+
6+
spawn(
7+
process.execPath,
8+
[
9+
'--no-warnings',
10+
'--test-reporter', 'tap',
11+
'--test-random-seed=12345',
12+
'--test',
13+
fixtures.path('test-runner/shards/*.cjs'),
14+
],
15+
{ stdio: 'inherit' },
16+
);

0 commit comments

Comments
 (0)