Skip to content

Commit 2eeb519

Browse files
committed
test_runner: support test level randomization
1 parent 08db017 commit 2eeb519

15 files changed

+775
-59
lines changed

doc/api/cli.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2768,9 +2768,9 @@ option set. This flag is not necessary when test isolation is disabled.
27682768
added: REPLACEME
27692769
-->
27702770

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`.
2771+
Set the seed used to randomize test execution order. This applies to both test
2772+
file execution order and queued tests within each file. Providing this flag
2773+
enables randomization implicitly, even without `--test-randomize`.
27742774

27752775
The value must be an integer between `0` and `4294967295`.
27762776

@@ -2782,13 +2782,26 @@ This flag cannot be used with `--watch`.
27822782
added: REPLACEME
27832783
-->
27842784

2785-
Randomize the order in which test files are executed. This can help detect
2786-
tests that rely on shared state or execution order.
2785+
Randomize test execution order. This applies to both test file execution order
2786+
and queued tests within each file. This can help detect tests that rely on
2787+
shared state or execution order.
27872788

27882789
The seed used for randomization is printed in the test summary and can be
27892790
reused with `--test-random-seed`.
27902791

2791-
This flag cannot be used with `--watch`.
2792+
For detailed behavior and examples, see the
2793+
[test runner][] documentation, especially the
2794+
`Randomizing test file execution order` section.
2795+
2796+
#### Limitations
2797+
2798+
* Randomization is applied when there are multiple queued sibling tests.
2799+
Patterns that explicitly await each `test()` call (for example,
2800+
`await test(...)` inside a loop) run subtests sequentially and preserve
2801+
declaration order, so their execution order is not randomized.
2802+
* Suite-style APIs such as `describe()`/`it()` or `suite()`/`test()` do not
2803+
disable randomization when sibling tests are queued.
2804+
* This flag cannot be used with `--watch`.
27922805

27932806
### `--test-reporter`
27942807

doc/api/test.md

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -593,8 +593,10 @@ added: REPLACEME
593593

594594
> Stability: 1.0 - Early development
595595
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.
596+
The test runner can randomize execution order to help detect
597+
order-dependent tests. When enabled, the runner randomizes both discovered
598+
test files and queued tests within each file. Use `--test-randomize` to
599+
enable this mode.
598600

599601
```bash
600602
node --test --test-randomize
@@ -607,14 +609,68 @@ as a diagnostic message:
607609
Randomized test order seed: 12345
608610
```
609611

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:
612+
Use `--test-random-seed=<number>` to replay the same randomized order
613+
deterministically. Supplying `--test-random-seed` also enables randomization,
614+
so `--test-randomize` is optional when a seed is provided:
613615

614616
```bash
615617
node --test --test-randomize --test-random-seed=12345
616618
```
617619

620+
In most test files, randomization works automatically. One important exception
621+
is when subtests are awaited one by one. In that pattern, each subtest starts
622+
only after the previous one finishes, so the runner keeps declaration order
623+
instead of randomizing it.
624+
625+
Example: this runs sequentially and is **not** randomized.
626+
627+
```mjs
628+
import test from 'node:test';
629+
630+
test('math', async (t) => {
631+
for (const name of ['adds', 'subtracts', 'multiplies']) {
632+
// Sequentially awaiting each subtest preserves declaration order.
633+
await t.test(name, async () => {});
634+
}
635+
});
636+
```
637+
638+
```cjs
639+
const test = require('node:test');
640+
641+
test('math', async (t) => {
642+
for (const name of ['adds', 'subtracts', 'multiplies']) {
643+
// Sequentially awaiting each subtest preserves declaration order.
644+
await t.test(name, async () => {});
645+
}
646+
});
647+
```
648+
649+
Using suite-style APIs such as `describe()`/`it()` or `suite()`/`test()`
650+
still allows randomization, because sibling tests are queued together.
651+
652+
Example: this remains eligible for randomization.
653+
654+
```mjs
655+
import { describe, it } from 'node:test';
656+
657+
describe('math', () => {
658+
it('adds', () => {});
659+
it('subtracts', () => {});
660+
it('multiplies', () => {});
661+
});
662+
```
663+
664+
```cjs
665+
const { describe, it } = require('node:test');
666+
667+
describe('math', () => {
668+
it('adds', () => {});
669+
it('subtracts', () => {});
670+
it('multiplies', () => {});
671+
});
672+
```
673+
618674
`--test-randomize` and `--test-random-seed` are not supported with `--watch` mode.
619675

620676
Matching files are executed as test files.
@@ -657,8 +713,10 @@ test runner functionality:
657713
* `--test-reporter` - Reporting is managed by the parent process
658714
* `--test-reporter-destination` - Output destinations are controlled by the parent
659715
* `--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
716+
* `--test-randomize` - Randomization is managed by the parent process and
717+
propagated to child processes
718+
* `--test-random-seed` - Randomization seed is managed by the parent process and
719+
propagated to child processes
662720

663721
All other Node.js options from command line arguments, environment variables,
664722
and configuration files are inherited by the child processes.
@@ -1565,12 +1623,12 @@ changes:
15651623
that specifies the index of the shard to run. This option is _required_.
15661624
* `total` {number} is a positive integer that specifies the total number
15671625
of shards to split the test files to. This option is _required_.
1568-
* `randomize` {boolean} Randomize the execution order of test files.
1626+
* `randomize` {boolean} Randomize execution order for test files and queued tests.
15691627
This option is not supported with `watch: true`.
15701628
**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.
1629+
* `randomSeed` {number} Seed used when randomizing execution order. If this
1630+
option is set, runs can replay the same randomized order deterministically,
1631+
and setting this option also enables randomization.
15741632
**Default:** `undefined`.
15751633
* `rerunFailuresFilePath` {string} A file path where the test runner will
15761634
store the state of the tests to allow rerunning only the failed tests on a next run.

lib/internal/test_runner/runner.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ function getRunArgs(path, { forceExit,
155155
argv: suppliedArgs,
156156
execArgv,
157157
rerunFailuresFilePath,
158+
randomize,
159+
randomSeed,
158160
root: { timeout },
159161
cwd }) {
160162
const processNodeOptions = getOptionsAsFlagsFromBinding();
@@ -196,6 +198,12 @@ function getRunArgs(path, { forceExit,
196198
if (rerunFailuresFilePath) {
197199
ArrayPrototypePush(runArgs, `--test-rerun-failures=${rerunFailuresFilePath}`);
198200
}
201+
if (randomize === true) {
202+
ArrayPrototypePush(runArgs, '--test-randomize');
203+
}
204+
if (randomSeed != null) {
205+
ArrayPrototypePush(runArgs, `--test-random-seed=${randomSeed}`);
206+
}
199207

200208
ArrayPrototypePushApply(runArgs, execArgv);
201209

@@ -682,6 +690,25 @@ function run(options = kEmptyObject) {
682690
);
683691
}
684692
}
693+
if (rerunFailuresFilePath) {
694+
validatePath(rerunFailuresFilePath, 'options.rerunFailuresFilePath');
695+
// TODO(pmarchini): Support rerun-failures with randomization by
696+
// persisting the randomization seed in the rerun state file.
697+
if (randomSeed != null) {
698+
throw new ERR_INVALID_ARG_VALUE(
699+
'options.randomSeed',
700+
randomSeed,
701+
'is not supported with rerun failures mode',
702+
);
703+
}
704+
if (randomize) {
705+
throw new ERR_INVALID_ARG_VALUE(
706+
'options.randomize',
707+
randomize,
708+
'is not supported with rerun failures mode',
709+
);
710+
}
711+
}
685712
if (randomize) {
686713
randomSeed ??= createRandomSeed();
687714
}
@@ -694,10 +721,6 @@ function run(options = kEmptyObject) {
694721
);
695722
}
696723

697-
if (rerunFailuresFilePath) {
698-
validatePath(rerunFailuresFilePath, 'options.rerunFailuresFilePath');
699-
}
700-
701724
if (shard != null) {
702725
validateObject(shard, 'options.shard');
703726
// Avoid re-evaluating the shard object in case it's a getter
@@ -794,6 +817,8 @@ function run(options = kEmptyObject) {
794817
functionCoverage: functionCoverage,
795818
cwd,
796819
globalSetupPath,
820+
randomize,
821+
randomSeed,
797822
};
798823

799824
const root = createTestTree(rootTestOptions, globalOptions);

lib/internal/test_runner/test.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const {
1010
ArrayPrototypeUnshiftApply,
1111
Error,
1212
FunctionPrototype,
13+
MathFloor,
1314
MathMax,
1415
Number,
1516
NumberPrototypeToFixed,
@@ -47,6 +48,7 @@ const { MockTracker } = require('internal/test_runner/mock/mock');
4748
const { TestsStream } = require('internal/test_runner/tests_stream');
4849
const {
4950
createDeferredCallback,
51+
createSeededGenerator,
5052
countCompletedTest,
5153
isTestFailureError,
5254
reporterScope,
@@ -648,11 +650,16 @@ class Test extends AsyncResource {
648650
this.message = typeof skip === 'string' ? skip :
649651
typeof todo === 'string' ? todo : null;
650652
this.activeSubtests = 0;
653+
this.subtestQueueRandom = this.config.randomize ?
654+
createSeededGenerator(this.config.randomSeed) :
655+
null;
651656
this.pendingSubtests = [];
652657
this.readySubtests = new SafeMap();
653658
this.unfinishedSubtests = new SafeSet();
654659
this.subtestsPromise = null;
655660
this.subtests = [];
661+
this.nextReportOrder = 1;
662+
this.reportOrder = 0;
656663
this.waitingOn = 0;
657664
this.finished = false;
658665
this.hooks = {
@@ -776,13 +783,37 @@ class Test extends AsyncResource {
776783
ArrayPrototypePush(this.pendingSubtests, deferred);
777784
}
778785

786+
/**
787+
* Ensure each subtest has a contiguous, per-parent reporting order.
788+
* This is assigned at dequeue time for randomized runs, but tests that are
789+
* cancelled before dequeue still need an order to be reported.
790+
* @param {Test} subtest
791+
* @returns {void}
792+
*/
793+
assignReportOrder(subtest) {
794+
if (subtest.reportOrder === 0) {
795+
subtest.reportOrder = this.nextReportOrder++;
796+
}
797+
}
798+
799+
dequeuePendingSubtest() {
800+
if (!this.subtestQueueRandom || this.pendingSubtests.length < 2) {
801+
return ArrayPrototypeShift(this.pendingSubtests);
802+
}
803+
804+
// Pick a uniformly random pending sibling when randomization is enabled.
805+
const index = MathFloor(this.subtestQueueRandom() * this.pendingSubtests.length);
806+
return ArrayPrototypeSplice(this.pendingSubtests, index, 1)[0];
807+
}
808+
779809
/**
780810
* @returns {Promise<void>}
781811
*/
782812
async processPendingSubtests() {
783813
while (this.pendingSubtests.length > 0 && this.hasConcurrency()) {
784-
const deferred = ArrayPrototypeShift(this.pendingSubtests);
814+
const deferred = this.dequeuePendingSubtest();
785815
const test = deferred.test;
816+
this.assignReportOrder(test);
786817
test.reporter.dequeue(test.nesting, test.loc, test.name, this.reportedType);
787818
await test.run();
788819
deferred.resolve();
@@ -794,7 +825,8 @@ class Test extends AsyncResource {
794825
* @returns {void}
795826
*/
796827
addReadySubtest(subtest) {
797-
this.readySubtests.set(subtest.childNumber, subtest);
828+
this.assignReportOrder(subtest);
829+
this.readySubtests.set(subtest.reportOrder, subtest);
798830

799831
if (this.unfinishedSubtests.delete(subtest) &&
800832
this.unfinishedSubtests.size === 0) {
@@ -1011,6 +1043,7 @@ class Test extends AsyncResource {
10111043
return deferred.promise;
10121044
}
10131045

1046+
this.parent.assignReportOrder(this);
10141047
this.reporter.dequeue(this.nesting, this.loc, this.name, this.reportedType);
10151048
return this.run();
10161049
}
@@ -1315,7 +1348,7 @@ class Test extends AsyncResource {
13151348
isClearToSend() {
13161349
return this.parent === null ||
13171350
(
1318-
this.parent.waitingOn === this.childNumber && this.parent.isClearToSend()
1351+
this.parent.waitingOn === this.reportOrder && this.parent.isClearToSend()
13191352
);
13201353
}
13211354

src/node_options.cc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -988,15 +988,15 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
988988
kAllowedInEnvvar,
989989
OptionNamespaces::kTestRunnerNamespace);
990990
AddOption("--test-randomize",
991-
"run test files in a random order",
991+
"run tests in a random order",
992992
&EnvironmentOptions::test_randomize,
993993
kAllowedInEnvvar,
994994
false,
995995
OptionNamespaces::kTestRunnerNamespace);
996996
AddOption(
997997
"[has_test_random_seed]", "", &EnvironmentOptions::has_test_random_seed);
998998
AddOption("--test-random-seed",
999-
"seed used to randomize test file execution order",
999+
"seed used to randomize test execution order",
10001000
&EnvironmentOptions::test_random_seed,
10011001
kAllowedInEnvvar,
10021002
OptionNamespaces::kTestRunnerNamespace);
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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', 'spec',
11+
'--test-random-seed=1',
12+
'--test-isolation=none',
13+
'--test',
14+
fixtures.path('test-runner/randomize/internal-order-nested-scenarios.cjs'),
15+
],
16+
{ stdio: 'inherit' },
17+
);

0 commit comments

Comments
 (0)