Skip to content

Commit 08db017

Browse files
committed
test_runner: support test order randomization
1 parent ec88813 commit 08db017

File tree

13 files changed

+564
-3
lines changed

13 files changed

+564
-3
lines changed

doc/api/cli.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2762,6 +2762,34 @@ changes:
27622762
Configures the test runner to only execute top level tests that have the `only`
27632763
option set. This flag is not necessary when test isolation is disabled.
27642764

2765+
### `--test-random-seed`
2766+
2767+
<!-- YAML
2768+
added: REPLACEME
2769+
-->
2770+
2771+
Set the seed used to randomize the order in which test files are executed.
2772+
Providing this flag enables randomization implicitly, even without
2773+
`--test-randomize`.
2774+
2775+
The value must be an integer between `0` and `4294967295`.
2776+
2777+
This flag cannot be used with `--watch`.
2778+
2779+
### `--test-randomize`
2780+
2781+
<!-- YAML
2782+
added: REPLACEME
2783+
-->
2784+
2785+
Randomize the order in which test files are executed. This can help detect
2786+
tests that rely on shared state or execution order.
2787+
2788+
The seed used for randomization is printed in the test summary and can be
2789+
reused with `--test-random-seed`.
2790+
2791+
This flag cannot be used with `--watch`.
2792+
27652793
### `--test-reporter`
27662794

27672795
<!-- YAML
@@ -3679,6 +3707,8 @@ one is included in the list below.
36793707
* `--test-isolation`
36803708
* `--test-name-pattern`
36813709
* `--test-only`
3710+
* `--test-random-seed`
3711+
* `--test-randomize`
36823712
* `--test-reporter-destination`
36833713
* `--test-reporter`
36843714
* `--test-rerun-failures`

doc/api/test.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,38 @@ 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+
<!-- YAML
591+
added: REPLACEME
592+
-->
593+
594+
> Stability: 1.0 - Early development
595+
596+
The test runner can randomize the order of discovered test files to help detect
597+
order-dependent tests. Use `--test-randomize` to enable this mode.
598+
599+
```bash
600+
node --test --test-randomize
601+
```
602+
603+
When randomization is enabled, the test runner prints the seed used for the run
604+
as a diagnostic message:
605+
606+
```text
607+
Randomized test order seed: 12345
608+
```
609+
610+
Use `--test-random-seed=<number>` to replay the same order in a deterministic
611+
way. Supplying `--test-random-seed` also enables randomization, so
612+
`--test-randomize` is optional when a seed is provided:
613+
614+
```bash
615+
node --test --test-randomize --test-random-seed=12345
616+
```
617+
618+
`--test-randomize` and `--test-random-seed` are not supported with `--watch` mode.
619+
588620
Matching files are executed as test files.
589621
More information on the test file execution can be found
590622
in the [test runner execution model][] section.
@@ -625,6 +657,8 @@ test runner functionality:
625657
* `--test-reporter` - Reporting is managed by the parent process
626658
* `--test-reporter-destination` - Output destinations are controlled by the parent
627659
* `--experimental-config-file` - Config file paths are managed by the parent
660+
* `--test-randomize` - File randomization is managed by the parent process
661+
* `--test-random-seed` - File randomization seed is managed by the parent process
628662

629663
All other Node.js options from command line arguments, environment variables,
630664
and configuration files are inherited by the child processes.
@@ -1531,6 +1565,13 @@ changes:
15311565
that specifies the index of the shard to run. This option is _required_.
15321566
* `total` {number} is a positive integer that specifies the total number
15331567
of shards to split the test files to. This option is _required_.
1568+
* `randomize` {boolean} Randomize the execution order of test files.
1569+
This option is not supported with `watch: true`.
1570+
**Default:** `false`.
1571+
* `randomSeed` {number} Seed used when randomizing test file order. If this
1572+
option is set, runs can replay the same randomized file order
1573+
deterministically, and setting this option also enables randomization.
1574+
**Default:** `undefined`.
15341575
* `rerunFailuresFilePath` {string} A file path where the test runner will
15351576
store the state of the tests to allow rerunning only the failed tests on a next run.
15361577
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
{

doc/node.1

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1357,6 +1357,20 @@ tests must satisfy \fBboth\fR requirements in order to be executed.
13571357
Configures the test runner to only execute top level tests that have the \fBonly\fR
13581358
option set. This flag is not necessary when test isolation is disabled.
13591359
.
1360+
.It Fl -test-random-seed
1361+
Set the seed used to randomize the order in which test files are executed.
1362+
Providing this flag enables randomization implicitly, even without
1363+
\fB--test-randomize\fR.
1364+
The value must be an integer between 0 and 4294967295.
1365+
This flag cannot be used with \fB--watch\fR.
1366+
.
1367+
.It Fl -test-randomize
1368+
Randomize the order in which test files are executed.
1369+
This can help detect tests that rely on shared state or execution order.
1370+
The seed used for randomization is printed in the test summary and can be
1371+
reused with \fB--test-random-seed\fR.
1372+
This flag cannot be used with \fB--watch\fR.
1373+
.
13601374
.It Fl -test-reporter
13611375
A test reporter to use when running tests. See the documentation on
13621376
test reporters for more details.
@@ -2034,6 +2048,10 @@ one is included in the list below.
20342048
.It
20352049
\fB--test-reporter-destination\fR
20362050
.It
2051+
\fB--test-randomize\fR
2052+
.It
2053+
\fB--test-random-seed\fR
2054+
.It
20372055
\fB--test-reporter\fR
20382056
.It
20392057
\fB--test-rerun-failures\fR

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,

0 commit comments

Comments
 (0)