ReviewOS

also looking at this

zig-utils/zig-test-framework

Add reproducible randomized test order

#29
Merged glennmichael123 wants to merge codex/issue-14-random-order into main
18 files +487 -42
README.mdmodified+17-3
Changes to README.md
@@ -9,6 +9,7 @@ A modern, feature-rich testing framework for Zig inspired by Jest, Vitest, and B
99- **Async Test Support** - Full async test execution with concurrent and sequential modes
1010- **Timeout Handling** - Configurable timeouts at test, suite, and global levels with extension support
1111- **Retries & Repeats** - Re-run failures, stress-run selected tests, and report flaky outcomes
12- **Reproducible Randomization** - Shuffle suites and tests with replayable seeds
1213- **Familiar API** - Describe/it syntax similar to Jest and Vitest
1314- **Rich Assertions** - Comprehensive assertion library with `.expect()` and matchers
1415- **Error Assertions** - `toThrow()` and `toThrowError()` for testing error handling
@@ -102,6 +103,8 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d
102103- `--retry <N>` - Retry a failed test or discovered test file up to N times
103104- `--repeat <N>` - Require N successful repetitions of every selected test
104105- `--fail-on-flaky` - Return a failing exit status when a retry recovers a failure
106- `--shuffle` - Randomize suites, tests, or discovered files with a generated seed
107- `--seed <N>` - Replay a randomized order; supplying a seed implies `--shuffle`
105108- `--shard-index <N>` - Run one one-based file shard
106109- `--shard-count <N>` - Set the total number of file shards
107110- `--no-color` - Disable color in child Zig test processes
@@ -139,6 +142,10 @@ zig-test --test-dir tests --retry 2 --fail-on-flaky
139142# Stress-run matching tests 100 times
140143zig-test --test-dir tests --filter parser --repeat 100
141144
145# Expose order dependencies, then replay the exact order with the printed seed
146zig-test --test-dir tests --shuffle
147zig-test --test-dir tests --seed 8675309
148
142149# Run the second of four deterministic file shards
143150zig-test --test-dir tests --shard-index 2 --shard-count 4
144151```
@@ -185,7 +192,9 @@ precedence over configured values.
185192 "filter": "database",
186193 "retries": 2,
187194 "repeat": 10,
188 "fail_on_flaky": true
195 "fail_on_flaky": true,
196 "shuffle": true,
197 "seed": 8675309
189198 },
190199 "sharding": {
191200 "index": 1,
@@ -204,6 +213,8 @@ limitations as their CLI equivalents; unsupported combinations fail explicitly.
204213
205214See [Retries, repeats, and flaky tests](docs/retries-and-repeats.md) for attempt
206215semantics, exit codes, reporter output, and programmatic configuration.
216See [Randomized test order](docs/randomized-order.md) for replay and ordering
217guarantees.
207218
208219### Parallel execution semantics
209220
@@ -212,7 +223,8 @@ and set `n_jobs`. A configured value is the exact maximum number of active test
212223workers; omitting it uses the host's logical CPU count, with a one-worker
213224fallback when detection is unavailable.
214225
215Suites and nested suites are visited in declaration order. `beforeAll` and
226Suites and nested suites are visited in the selected order: declaration order
227by default, or deterministic seed order when randomization is enabled. `beforeAll` and
216228`afterAll` run once on the coordinating thread around that suite and its nested
217229suites. Direct tests within a suite may overlap. Their inherited `beforeEach`
218230and `afterEach` hooks run on the same worker as the test, so per-test hook order
@@ -220,7 +232,7 @@ is preserved but hooks from different tests may overlap. Hook state and the
220232allocator passed to tests must therefore be thread-safe.
221233
222234Reporter callbacks never run concurrently: test results are emitted in
223declaration order after each parallel batch, making output and final totals
235the selected order after each parallel batch, making output and final totals
224236deterministic. A parallel batch is already scheduled as a unit, so `bail` cannot
225237cancel tests that have started.
226238
@@ -665,6 +677,8 @@ zig-test --no-color
665677| `--retry <N>` | `--retries <N>` | Retry each failed test up to N times |
666678| `--repeat <N>` | | Require N successful runs of each selected test |
667679| `--fail-on-flaky` | | Fail the run when a retry recovers a failure |
680| `--shuffle` | | Randomize suite, test, and discovered-file order |
681| `--seed <N>` | | Replay a randomized order; implies `--shuffle` |
668682| `--verbose` | | Enable verbose output |
669683| `--quiet` | `-q` | Minimal output |
670684| `--no-color` | | Disable colored output |
docs/execution-pipeline.mdmodified+6-2
Changes to docs/execution-pipeline.md
@@ -34,6 +34,9 @@ reporters, including the configured JUnit output path.
3434 child process.
3535- Every logical result owns ordered attempt history. A failure followed by a
3636 successful retry is classified as `flaky`, distinct from an ordinary pass.
37- Optional randomized execution uses one run seed across the selected plan.
38 The seed is carried by reporters and `TestResults`; see
39 [Randomized test order](randomized-order.md) for mode-specific guarantees.
3740- Summaries always come from `TestResults`, rather than executor-local counters.
3841
3942## Extension points
@@ -53,10 +56,11 @@ model without coupling the execution API to JSON.
5356Existing programmatic APIs remain source compatible:
5457
5558- `TestRunner.init`, `runTests`, and `runTestsWithOptions` are unchanged.
56- `RunnerOptions` gains optional reporter, timeout, retry, repeat, and flaky-exit
59- `RunnerOptions` gains optional reporter, timeout, retry, repeat, flaky-exit,
60 shuffle, and seed
5761 fields with backward-compatible defaults.
5862- `runDiscoveredTests` and `LoaderOptions` remain available; optional reporter,
59 JUnit path, timeout, retry, repeat, flaky-exit, and writer fields default to
63 JUnit path, timeout, retry, repeat, flaky-exit, shuffle, seed, and writer fields default to
6064 the previous spec behavior.
6165
6266CLI hosts can inject a stdout writer with `reporter_writer`. Embedded callers
docs/json-event-protocol.mdmodified+4-2
Changes to docs/json-event-protocol.md
@@ -35,8 +35,8 @@ events are emitted in attempt order immediately before their `test_end`.
3535
3636| Type | Data fields |
3737| --- | --- |
38| `run_start` | `total` |
39| `run_end` | `total`, `passed`, `flaky`, `failed`, `skipped`, `duration_ns` |
38| `run_start` | `total`, optional `random_seed` |
39| `run_end` | `total`, `passed`, `flaky`, `failed`, `skipped`, `duration_ns`, optional `random_seed` |
4040| `suite_start`, `suite_end` | `name` |
4141| `test_start` | `name`, optional `suite` |
4242| `test_end` | `name`, optional `suite`, `status`, `duration_ns`, optional `error_message` |
@@ -51,6 +51,8 @@ Output streams are `stdout` or `stderr`.
5151
5252Optional fields are serialized as `null` when no value is available. Durations
5353are unsigned integer nanoseconds. Percentages are numbers from 0 through 100.
54When randomized ordering is enabled, both run events carry the reproducible
55unsigned integer seed.
5456
5557## Compatibility policy
5658
docs/randomized-order.mdadded+58-0
Changes to docs/randomized-order.md
@@ -0,0 +1,58 @@
1# Randomized test order
2
3Randomized order is opt-in. Use `--shuffle` to generate a seed for the run:
4
5```bash
6zig-test --shuffle
7```
8
9The selected reporter prints the generated seed at run start. If a test fails,
10the human-readable reporters print a replay command as well. Pass that seed to
11reproduce the order; `--seed` implies `--shuffle`:
12
13```bash
14zig-test --seed 8675309
15```
16
17Keep every other order-affecting option the same when replaying a run, including
18the test set, filter, shard, execution mode, and suite structure.
19
20## Ordering guarantees
21
22Without `--shuffle` or `--seed`, execution retains declaration or discovery
23order.
24
25In sequential programmatic execution, a seed deterministically orders root
26suites, each suite's direct tests, and its nested suites. Lifecycle hooks keep
27their normal scope: `beforeAll` and `afterAll` still wrap a suite, while
28`beforeEach` and `afterEach` still wrap their selected test.
29
30In parallel programmatic execution, the seed determines suite traversal, each
31worker queue, and serialized reporter output. Workers claim tests from that
32queue in order, but operating-system scheduling means wall-clock start and
33completion timing can differ between runs. Reporter order remains reproducible.
34
35In discovery mode, sharding selects files first and randomization orders the
36files inside the selected shard. The seed does not change shard membership.
37Retries and repetitions stay attached to their logical test or file and do not
38trigger another shuffle.
39
40## Metadata
41
42The seed is stored as `random_seed` in JSON `run_start` and `run_end` events,
43live UI events, and test-history files. JUnit output records it as a
44`random_seed` property on each test suite. A non-randomized run reports a null
45seed in JSON-compatible formats and omits the JUnit property.
46
47Programmatic callers can use `RunnerOptions.shuffle` and `RunnerOptions.seed`,
48or the matching `LoaderOptions` fields. JSON configuration accepts the same
49values under `test`:
50
51```json
52{
53 "test": {
54 "shuffle": true,
55 "seed": 8675309
56 }
57}
58```
src/cli.zigmodified+34-0
Changes to src/cli.zig
@@ -49,6 +49,8 @@ pub const CLIOptions = struct {
4949 retries: usize = 0,
5050 repeat: usize = 1,
5151 fail_on_flaky: bool = false,
52 shuffle: bool = false,
53 seed: ?u64 = null,
5254};
5355
5456pub const version = build_options.version;
@@ -82,6 +84,8 @@ pub const CLI = struct {
8284 self.options.retries = config.test_options.retries;
8385 self.options.repeat = config.test_options.repeat;
8486 self.options.fail_on_flaky = config.test_options.fail_on_flaky;
87 self.options.shuffle = config.test_options.shuffle or config.test_options.seed != null;
88 self.options.seed = config.test_options.seed;
8589 self.options.shard_index = config.sharding.index;
8690 self.options.shard_count = config.sharding.count;
8791 self.options.parallel = config.parallel.enabled;
@@ -140,6 +144,19 @@ pub const CLI = struct {
140144 }
141145 } else if (std.mem.eql(u8, arg, "--fail-on-flaky")) {
142146 self.options.fail_on_flaky = true;
147 } else if (std.mem.eql(u8, arg, "--shuffle")) {
148 self.options.shuffle = true;
149 } else if (std.mem.eql(u8, arg, "--seed")) {
150 if (i + 1 >= args.len) {
151 std.debug.print("Error: --seed requires a value\n", .{});
152 return CLIError.MissingValue;
153 }
154 i += 1;
155 self.options.seed = std.fmt.parseInt(u64, args[i], 10) catch {
156 std.debug.print("Error: --seed must be a valid unsigned integer\n", .{});
157 return CLIError.InvalidArgument;
158 };
159 self.options.shuffle = true;
143160 } else if (std.mem.eql(u8, arg, "--verbose")) {
144161 self.options.verbose = true;
145162 } else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q")) {
@@ -386,6 +403,8 @@ pub const CLI = struct {
386403 \\ --retry <N> Retry each failed test up to N times
387404 \\ --repeat <N> Require N successful runs of each selected test
388405 \\ --fail-on-flaky Exit unsuccessfully when a retry recovers a failure
406 \\ --shuffle Randomize suite and test order
407 \\ --seed <N> Reproduce a randomized order (implies --shuffle)
389408 \\ --filter <pattern> Run only tests matching pattern
390409 \\ --grep <pattern> Same as --filter (alias)
391410 \\ --reporter <name> Set reporter type (spec, dot, json, tap, junit)
@@ -452,6 +471,8 @@ pub const CLI = struct {
452471 \\ zig-test --timeout 10000 Set 10s timeout for all tests
453472 \\ zig-test --retry 2 --fail-on-flaky Detect and reject flaky tests
454473 \\ zig-test --repeat 100 --filter parser Stress-run selected tests
474 \\ zig-test --shuffle Run in a generated random order
475 \\ zig-test --seed 12345 Replay a randomized order
455476 \\ zig-test --profile-memory --fail-on-leak
456477 \\ zig-test --parallel --jobs 4 Run with 4 parallel workers
457478 \\ zig-test --config zig-test.json Load config from file
@@ -482,6 +503,8 @@ pub const CLI = struct {
482503 .retries = self.options.retries,
483504 .repeat = self.options.repeat,
484505 .fail_on_flaky = self.options.fail_on_flaky,
506 .shuffle = self.options.shuffle,
507 .seed = self.options.seed,
485508 };
486509 }
487510};
@@ -724,6 +747,17 @@ test "CLI parses retry repeat and flaky exit policy" {
724747 try std.testing.expect(runner_options.fail_on_flaky);
725748}
726749
750test "CLI parses shuffle and a seed implies it" {
751 var cli = CLI.init(std.testing.allocator);
752 try cli.parse(&.{ "zig-test", "--seed", "12345" });
753 try std.testing.expect(cli.options.shuffle);
754 try std.testing.expectEqual(@as(?u64, 12345), cli.options.seed);
755
756 const runner_options = cli.toRunnerOptions();
757 try std.testing.expect(runner_options.shuffle);
758 try std.testing.expectEqual(@as(?u64, 12345), runner_options.seed);
759}
760
727761test "CLI rejects a zero repeat count" {
728762 var cli = CLI.init(std.testing.allocator);
729763 try std.testing.expectError(
src/config.zigmodified+6-0
Changes to src/config.zig
@@ -32,6 +32,8 @@ pub const TestOptions = struct {
3232 retries: usize = 0,
3333 repeat: usize = 1,
3434 fail_on_flaky: bool = false,
35 shuffle: bool = false,
36 seed: ?u64 = null,
3537};
3638
3739pub const ParallelOptions = struct {
@@ -207,6 +209,8 @@ test "TestConfig defaults match CLI discovery defaults" {
207209 try std.testing.expectEqual(@as(usize, 0), config.test_options.retries);
208210 try std.testing.expectEqual(@as(usize, 1), config.test_options.repeat);
209211 try std.testing.expect(!config.test_options.fail_on_flaky);
212 try std.testing.expect(!config.test_options.shuffle);
213 try std.testing.expect(config.test_options.seed == null);
210214 try std.testing.expect(config.sharding.index == null);
211215 try std.testing.expect(config.sharding.count == null);
212216 try std.testing.expect(!config.parallel.enabled);
@@ -247,6 +251,8 @@ test "ConfigLoader loads supported JSON and rejects unknown keys" {
247251 try std.testing.expectEqual(@as(usize, 1), config.test_options.retries);
248252 try std.testing.expectEqual(@as(usize, 2), config.test_options.repeat);
249253 try std.testing.expect(!config.test_options.fail_on_flaky);
254 try std.testing.expect(config.test_options.shuffle);
255 try std.testing.expectEqual(@as(?u64, 42), config.test_options.seed);
250256 try std.testing.expectEqual(@as(?usize, 1), config.sharding.index);
251257 try std.testing.expectEqual(@as(?usize, 1), config.sharding.count);
252258
src/event_protocol.zigmodified+5-1
Changes to src/event_protocol.zig
@@ -7,7 +7,10 @@ pub const Status = enum { pending, running, passed, flaky, failed, skipped };
77pub const HookKind = enum { before_all, before_each, after_each, after_all };
88pub const OutputStream = enum { stdout, stderr };
99
10pub const RunStart = struct { total: usize };
10pub const RunStart = struct {
11 total: usize,
12 random_seed: ?u64 = null,
13};
1114pub const RunEnd = struct {
1215 total: usize,
1316 passed: usize,
@@ -15,6 +18,7 @@ pub const RunEnd = struct {
1518 failed: usize,
1619 skipped: usize,
1720 duration_ns: u64 = 0,
21 random_seed: ?u64 = null,
1822};
1923pub const Suite = struct { name: []const u8 };
2024pub const TestStart = struct { name: []const u8, suite: ?[]const u8 = null };
src/lib.zigmodified+1-0
Changes to src/lib.zig
@@ -27,6 +27,7 @@ pub const timeout = ;
2727pub const progress = ;
2828pub const time = ;
2929pub const compat = ;
30pub const random_order = ;
3031
3132// Re-export commonly used types and functions
3233pub const expect = assertions.expect;
src/main.zigmodified+4-0
Changes to src/main.zig
@@ -189,6 +189,8 @@ pub fn main(init: std.process.Init.Minimal) !void {
189189 .retries = cli_parser.options.retries,
190190 .repeat = cli_parser.options.repeat,
191191 .fail_on_flaky = cli_parser.options.fail_on_flaky,
192 .shuffle = cli_parser.options.shuffle,
193 .seed = cli_parser.options.seed,
192194 .reporter_writer = &stdout_writer.interface,
193195 .coverage_options = cov_opts,
194196 .ui_server = if (ui_server) |*server| server else null,
@@ -235,6 +237,8 @@ pub fn main(init: std.process.Init.Minimal) !void {
235237 .retries = cli_parser.options.retries,
236238 .repeat = cli_parser.options.repeat,
237239 .fail_on_flaky = cli_parser.options.fail_on_flaky,
240 .shuffle = cli_parser.options.shuffle,
241 .seed = cli_parser.options.seed,
238242 .reporter_writer = &stdout_writer.interface,
239243 .coverage_options = cov_opts,
240244 .ui_server = if (ui_server) |*server| server else null,
src/parallel.zigmodified+74-8
Changes to src/parallel.zig
@@ -3,6 +3,7 @@ const suite = ;
33const reporter = ;
44const pipeline = ;
55const compat = ;
6const random_order = ;
67
78/// Options for bounded parallel test execution.
89pub const ParallelOptions = struct {
@@ -12,6 +13,7 @@ pub const ParallelOptions = struct {
1213 enabled: bool = false,
1314 filter: ?[]const u8 = null,
1415 timeout_ms: ?u64 = null,
16 random_seed: ?u64 = null,
1517};
1618
1719pub const ParallelError = error{
@@ -111,6 +113,7 @@ const RunContext = struct {
111113 rep: *reporter.Reporter,
112114 options: ParallelOptions,
113115 worker_limit: usize,
116 shuffler: ?*random_order.Shuffler,
114117
115118 fn runSuite(self: *RunContext, test_suite: *suite.TestSuite) !bool {
116119 try self.rep.onSuiteStart(test_suite.name);
@@ -131,7 +134,12 @@ const RunContext = struct {
131134 var scheduled: std.ArrayList(*suite.TestCase) = .empty;
132135 defer scheduled.deinit(self.allocator);
133136
134 for (test_suite.tests.items) |*test_case| {
137 var ordered_tests: std.ArrayList(*suite.TestCase) = .empty;
138 defer ordered_tests.deinit(self.allocator);
139 for (test_suite.tests.items) |*test_case| try ordered_tests.append(self.allocator, test_case);
140 if (self.shuffler) |randomizer| randomizer.shuffle(*suite.TestCase, ordered_tests.items);
141
142 for (ordered_tests.items) |test_case| {
135143 if (!self.shouldRun(test_suite, test_case)) {
136144 test_case.status = .skipped;
137145 continue;
@@ -143,12 +151,16 @@ const RunContext = struct {
143151 try self.runBatch(test_suite, scheduled.items);
144152
145153 var all_passed = true;
146 for (test_suite.tests.items) |*test_case| {
154 for (ordered_tests.items) |test_case| {
147155 try self.rep.onTestEnd(test_case);
148156 if (test_case.status == .failed) all_passed = false;
149157 }
150158
151 for (test_suite.suites.items) |nested_suite| {
159 var nested_suites: std.ArrayList(*suite.TestSuite) = .empty;
160 defer nested_suites.deinit(self.allocator);
161 try nested_suites.appendSlice(self.allocator, test_suite.suites.items);
162 if (self.shuffler) |randomizer| randomizer.shuffle(*suite.TestSuite, nested_suites.items);
163 for (nested_suites.items) |nested_suite| {
152164 if (!try self.runSuite(nested_suite)) all_passed = false;
153165 }
154166
@@ -201,11 +213,19 @@ const RunContext = struct {
201213 }
202214
203215 fn reportSkippedSuite(self: *RunContext, test_suite: *suite.TestSuite) !void {
204 for (test_suite.tests.items) |*test_case| {
216 var tests: std.ArrayList(*suite.TestCase) = .empty;
217 defer tests.deinit(self.allocator);
218 for (test_suite.tests.items) |*test_case| try tests.append(self.allocator, test_case);
219 if (self.shuffler) |randomizer| randomizer.shuffle(*suite.TestCase, tests.items);
220 for (tests.items) |test_case| {
205221 test_case.status = .skipped;
206222 try self.rep.onTestEnd(test_case);
207223 }
208 for (test_suite.suites.items) |nested| {
224 var nested_suites: std.ArrayList(*suite.TestSuite) = .empty;
225 defer nested_suites.deinit(self.allocator);
226 try nested_suites.appendSlice(self.allocator, test_suite.suites.items);
227 if (self.shuffler) |randomizer| randomizer.shuffle(*suite.TestSuite, nested_suites.items);
228 for (nested_suites.items) |nested| {
209229 try self.rep.onSuiteStart(nested.name);
210230 try self.reportSkippedSuite(nested);
211231 try self.rep.onSuiteEnd(nested.name);
@@ -213,9 +233,9 @@ const RunContext = struct {
213233 }
214234};
215235
216/// Run suites in declaration order while executing each suite's direct tests
236/// Run suites in the selected deterministic order while executing each suite's direct tests
217237/// with a bounded worker set. Reporter callbacks are serialized and emitted in
218/// declaration order after each batch completes.
238/// the selected order after each batch completes.
219239pub fn runTestsParallel(
220240 allocator: std.mem.Allocator,
221241 test_registry: *suite.TestRegistry,
@@ -226,23 +246,36 @@ pub fn runTestsParallel(
226246 const worker_limit = try resolveWorkerCount(options.n_jobs);
227247
228248 const total_tests = test_registry.countAllTests();
249 rep.random_seed = options.random_seed;
229250 try rep.onRunStart(total_tests);
230251
252 var shuffler: random_order.Shuffler = undefined;
253 const shuffler_ptr: ?*random_order.Shuffler = if (options.random_seed) |seed| blk: {
254 shuffler = .init(seed);
255 break :blk &shuffler;
256 } else null;
257
231258 var context = RunContext{
232259 .allocator = allocator,
233260 .registry = test_registry,
234261 .rep = rep,
235262 .options = options,
236263 .worker_limit = worker_limit,
264 .shuffler = shuffler_ptr,
237265 };
238266
267 var root_suites: std.ArrayList(*suite.TestSuite) = .empty;
268 defer root_suites.deinit(allocator);
269 try root_suites.appendSlice(allocator, test_registry.root_suites.items);
270 if (shuffler_ptr) |randomizer| randomizer.shuffle(*suite.TestSuite, root_suites.items);
239271 var all_passed = true;
240 for (test_registry.root_suites.items) |test_suite| {
272 for (root_suites.items) |test_suite| {
241273 if (!try context.runSuite(test_suite)) all_passed = false;
242274 }
243275
244276 var results = reporter.TestResults.init(allocator);
245277 defer results.deinit();
278 results.random_seed = options.random_seed;
246279 for (test_registry.root_suites.items) |test_suite| {
247280 try addSuiteResults(&results, test_suite);
248281 }
@@ -347,6 +380,7 @@ const RecordingReporter = struct {
347380 passed: usize = 0,
348381 failed: usize = 0,
349382 skipped: usize = 0,
383 random_seed: ?u64 = null,
350384
351385 fn init(allocator: std.mem.Allocator) RecordingReporter {
352386 return .{
@@ -379,6 +413,7 @@ const RecordingReporter = struct {
379413 recording.passed = results.passed;
380414 recording.failed = results.failed;
381415 recording.skipped = results.skipped;
416 recording.random_seed = results.random_seed;
382417 }
383418
384419 fn onSuiteStart(rep: *reporter.Reporter, _: []const u8) !void {
@@ -468,6 +503,37 @@ test "parallel reporting is serialized, ordered, and complete" {
468503 try std.testing.expectEqual(@as(usize, 1), recording.skipped);
469504}
470505
506test "parallel order is reproducible with a fixed seed" {
507 const allocator = std.heap.smp_allocator;
508 var registry = suite.TestRegistry.init(allocator);
509 defer registry.deinit();
510 const test_suite = try suite.TestSuite.init(allocator, "randomized");
511 inline for (.{ "one", "two", "three", "four", "five", "six" }) |name| {
512 try test_suite.addTest(suite.TestCase.init(name, passingTest));
513 }
514 try registry.registerSuite(test_suite);
515
516 var first = RecordingReporter.init(allocator);
517 try std.testing.expect(try runTestsParallel(allocator, &registry, &first.reporter, .{
518 .enabled = true,
519 .n_jobs = 2,
520 .random_seed = 8675309,
521 }));
522 var second = RecordingReporter.init(allocator);
523 try std.testing.expect(try runTestsParallel(allocator, &registry, &second.reporter, .{
524 .enabled = true,
525 .n_jobs = 2,
526 .random_seed = 8675309,
527 }));
528
529 try std.testing.expectEqual(first.ended_count, second.ended_count);
530 for (first.ended_names[0..first.ended_count], second.ended_names[0..second.ended_count]) |a, b| {
531 try std.testing.expectEqualStrings(a, b);
532 }
533 try std.testing.expectEqual(@as(?u64, 8675309), first.random_seed);
534 try std.testing.expectEqual(@as(?u64, 8675309), second.random_seed);
535}
536
471537test "parallel executor preserves nested hook lifecycles" {
472538 const counters = [_]*std.atomic.Value(usize){
473539 &parent_before_each,
src/random_order.zigadded+42-0
Changes to src/random_order.zig
@@ -0,0 +1,42 @@
1const std = ;
2const compat = ;
3
4/// Return the seed that should control this run. Supplying a seed enables
5/// shuffling even when `shuffle` is false; otherwise random order is opt-in.
6pub fn resolveSeed(shuffle: bool, configured: ?u64) ?u64 {
7 if (configured) |seed| return seed;
8 if (!shuffle) return null;
9 return @truncate(@as(u128, @bitCast(compat.nanoTimestamp())));
10}
11
12pub const Shuffler = struct {
13 prng: std.Random.DefaultPrng,
14
15 pub fn init(seed: u64) Shuffler {
16 return .{ .prng = std.Random.DefaultPrng.init(seed) };
17 }
18
19 pub fn shuffle(self: *Shuffler, comptime T: type, items: []T) void {
20 self.prng.random().shuffleWithIndex(T, items, u64);
21 }
22};
23
24test "random order is opt-in and an explicit seed enables it" {
25 try std.testing.expect(resolveSeed(false, null) == null);
26 try std.testing.expect(resolveSeed(true, null) != null);
27 try std.testing.expectEqual(@as(?u64, 0), resolveSeed(false, 0));
28 try std.testing.expectEqual(@as(?u64, 42), resolveSeed(true, 42));
29}
30
31test "the same seed produces the same order" {
32 var first = [_]u8{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
33 var second = first;
34 var first_shuffler = Shuffler.init(123456789);
35 var second_shuffler = Shuffler.init(123456789);
36
37 first_shuffler.shuffle(u8, &first);
38 second_shuffler.shuffle(u8, &second);
39
40 try std.testing.expectEqualSlices(u8, &first, &second);
41 try std.testing.expect(!std.mem.eql(u8, &first, &[_]u8{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }));
42}
src/reporter.zigmodified+25-6
Changes to src/reporter.zig
@@ -31,6 +31,7 @@ pub const Reporter = struct {
3131 vtable: *const VTable,
3232 allocator: std.mem.Allocator,
3333 use_colors: bool = true,
34 random_seed: ?u64 = null,
3435
3536 const Self = @This();
3637
@@ -76,6 +77,7 @@ pub const TestResults = struct {
7677 failed: usize = 0,
7778 skipped: usize = 0,
7879 total_time_ns: u64 = 0,
80 random_seed: ?u64 = null,
7981 failed_tests: std.ArrayList(suite.TestCase),
8082 flaky_tests: std.ArrayList(suite.TestCase),
8183 allocator: std.mem.Allocator,
@@ -163,6 +165,9 @@ pub const SpecReporter = struct {
163165 } else {
164166 try s.output().print("Running {d} test(s)...\n\n", .{total_tests});
165167 }
168 if (reporter.random_seed) |seed| {
169 try s.output().print("Random seed: {d}\n\n", .{seed});
170 }
166171 }
167172
168173 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
@@ -188,6 +193,9 @@ pub const SpecReporter = struct {
188193 }
189194 }
190195 try s.output().print("\n", .{});
196 if (results.random_seed) |seed| {
197 try s.output().print("Reproduce with: --seed {d}\n\n", .{seed});
198 }
191199 }
192200
193201 // Print summary
@@ -377,6 +385,7 @@ pub const DotReporter = struct {
377385 fn onRunStart(reporter: *Reporter, total_tests: usize) !void {
378386 const s = self(reporter);
379387 try s.output().print("\nRunning {d} tests:\n", .{total_tests});
388 if (reporter.random_seed) |seed| try s.output().print("Random seed: {d}\n", .{seed});
380389 }
381390
382391 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
@@ -399,6 +408,9 @@ pub const DotReporter = struct {
399408 total_time_ms,
400409 });
401410 }
411 if (results.failed > 0) {
412 if (results.random_seed) |seed| try s.output().print("Reproduce with: --seed {d}\n", .{seed});
413 }
402414 }
403415
404416 fn onSuiteStart(reporter: *Reporter, suite_name: []const u8) !void {
@@ -512,7 +524,7 @@ pub const JsonReporter = struct {
512524 s.emitted_events = false;
513525 s.suites.clearRetainingCapacity();
514526 try s.output().writeAll("[\n");
515 try s.emit(.{ .run_start = .{ .total = total_tests } });
527 try s.emit(.{ .run_start = .{ .total = total_tests, .random_seed = reporter.random_seed } });
516528 }
517529
518530 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
@@ -524,6 +536,7 @@ pub const JsonReporter = struct {
524536 .failed = results.failed,
525537 .skipped = results.skipped,
526538 .duration_ns = results.total_time_ns,
539 .random_seed = results.random_seed,
527540 } });
528541 try s.output().writeAll("\n]\n");
529542 }
@@ -618,11 +631,14 @@ pub const TAPReporter = struct {
618631 fn onRunStart(reporter: *Reporter, total: usize) !void {
619632 const self: *Self = @fieldParentPtr("reporter", reporter);
620633 try self.output().print("TAP version 14\n1..{d}\n", .{total});
634 if (reporter.random_seed) |seed| try self.output().print("# random-seed {d}\n", .{seed});
621635 }
622636
623637 fn onRunEnd(reporter: *Reporter, results: *TestResults) !void {
624 _ = reporter;
625 _ = results;
638 const self: *Self = @fieldParentPtr("reporter", reporter);
639 if (results.failed > 0) {
640 if (results.random_seed) |seed| try self.output().print("# reproduce --seed {d}\n", .{seed});
641 }
626642 }
627643
628644 fn onSuiteStart(reporter: *Reporter, suite_name: []const u8) !void {
@@ -753,14 +769,13 @@ pub const JUnitReporter = struct {
753769 fn onSuiteStart(reporter: *Reporter, suite_name: []const u8) !void {
754770 const self: *Self = @fieldParentPtr("reporter", reporter);
755771
756 const suite_result = try self.allocator.create(TestSuiteResult);
757 suite_result.* = .{
772 const suite_result = TestSuiteResult{
758773 .name = try self.allocator.dupe(u8, suite_name),
759774 .tests = .empty,
760775 .timestamp = compat.milliTimestamp(),
761776 };
762777
763 try self.suites.append(self.allocator, suite_result.*);
778 try self.suites.append(self.allocator, suite_result);
764779 self.current_suite = &self.suites.items[self.suites.items.len - 1];
765780 }
766781
@@ -843,6 +858,10 @@ pub const JUnitReporter = struct {
843858 suite_time,
844859 });
845860
861 if (results.random_seed) |seed| {
862 try buffer.print(self.allocator, " <properties><property name=\"random_seed\" value=\"{d}\"/></properties>\n", .{seed});
863 }
864
846865 for (suite_result.tests.items) |test_result| {
847866 try buffer.print(self.allocator, " <testcase name=\"{s}\" time=\"{d:.6}\"", .{
848867 test_result.name,
src/test_history.zigmodified+17-2
Changes to src/test_history.zig
@@ -13,6 +13,7 @@ pub const HistoryEntry = struct {
1313 failed: usize,
1414 skipped: usize,
1515 duration_ns: u64,
16 random_seed: ?u64 = null,
1617 tests: std.ArrayList(TestRecord),
1718
1819 pub fn deinit(self: *HistoryEntry, allocator: std.mem.Allocator) void {
@@ -65,6 +66,11 @@ pub const TestHistory = struct {
6566
6667 /// Start recording a new test run
6768 pub fn startRun(self: *Self, total: usize) !void {
69 try self.startRunWithSeed(total, null);
70 }
71
72 /// Start recording a run with optional randomized-order metadata.
73 pub fn startRunWithSeed(self: *Self, total: usize, random_seed: ?u64) !void {
6874 self.start_time = compat.milliTimestamp();
6975
7076 self.current_entry = HistoryEntry{
@@ -75,6 +81,7 @@ pub const TestHistory = struct {
7581 .failed = 0,
7682 .skipped = 0,
7783 .duration_ns = 0,
84 .random_seed = random_seed,
7885 .tests = std.ArrayList(TestRecord).empty,
7986 };
8087 }
@@ -117,6 +124,7 @@ pub const TestHistory = struct {
117124 self.current_entry.?.flaky = results.flaky;
118125 self.current_entry.?.failed = results.failed;
119126 self.current_entry.?.skipped = results.skipped;
127 self.current_entry.?.random_seed = results.random_seed orelse self.current_entry.?.random_seed;
120128
121129 try self.saveToFile();
122130 }
@@ -139,7 +147,10 @@ pub const TestHistory = struct {
139147 errdefer buffer = allocating.toArrayList();
140148 const writer = &allocating.writer;
141149 try writer.writeAll("[\n");
142 try std.json.Stringify.value(protocol.Event{ .run_start = .{ .total = self.current_entry.?.total } }, .{}, writer);
150 try std.json.Stringify.value(protocol.Event{ .run_start = .{
151 .total = self.current_entry.?.total,
152 .random_seed = self.current_entry.?.random_seed,
153 } }, .{}, writer);
143154 for (self.current_entry.?.tests.items) |test_record| {
144155 try writer.writeAll(",\n");
145156 try std.json.Stringify.value(protocol.Event{ .test_end = .{
@@ -158,6 +169,7 @@ pub const TestHistory = struct {
158169 .failed = self.current_entry.?.failed,
159170 .skipped = self.current_entry.?.skipped,
160171 .duration_ns = self.current_entry.?.duration_ns,
172 .random_seed = self.current_entry.?.random_seed,
161173 } }, .{}, writer);
162174 try writer.writeAll("\n]\n");
163175 try writer.flush();
@@ -192,6 +204,7 @@ pub const TestHistory = struct {
192204 .failed = 0,
193205 .skipped = 0,
194206 .duration_ns = 0,
207 .random_seed = null,
195208 .tests = std.ArrayList(TestRecord).empty,
196209 };
197210 }
@@ -258,7 +271,7 @@ test "TestHistory saves versioned protocol events" {
258271
259272 var history = TestHistory.init(allocator, history_dir);
260273 defer history.deinit();
261 try history.startRun(1);
274 try history.startRunWithSeed(1, 9876);
262275 try history.recordTest("quoted \"test\"", "suite\nname", .passed, 42, null);
263276 var results = reporter_mod.TestResults.init(allocator);
264277 defer results.deinit();
@@ -277,9 +290,11 @@ test "TestHistory saves versioned protocol events" {
277290 const events = parsed.value.array.items;
278291 try std.testing.expectEqual(@as(usize, 3), events.len);
279292 try std.testing.expectEqualStrings("run_start", events[0].object.get("type").?.string);
293 try std.testing.expectEqual(@as(i64, 9876), events[0].object.get("data").?.object.get("random_seed").?.integer);
280294 try std.testing.expectEqualStrings("test_end", events[1].object.get("type").?.string);
281295 try std.testing.expectEqualStrings("quoted \"test\"", events[1].object.get("data").?.object.get("name").?.string);
282296 try std.testing.expectEqualStrings("run_end", events[2].object.get("type").?.string);
297 try std.testing.expectEqual(@as(i64, 9876), events[2].object.get("data").?.object.get("random_seed").?.integer);
283298 for (events) |event| {
284299 try std.testing.expectEqual(@as(i64, protocol.version), event.object.get("protocol_version").?.integer);
285300 }
src/test_loader.zigmodified+18-2
Changes to src/test_loader.zig
@@ -8,6 +8,7 @@ const coverage = ;
88const ui_server = ;
99const compat = ;
1010const protocol = ;
11const random_order = ;
1112
1213/// Options for running discovered tests
1314pub const LoaderOptions = struct {
@@ -33,6 +34,10 @@ pub const LoaderOptions = struct {
3334 repeat: usize = 1,
3435 /// Treat recovered failures as an unsuccessful run.
3536 fail_on_flaky: bool = false,
37 /// Shuffle selected files. A generated seed is printed for replay.
38 shuffle: bool = false,
39 /// Reproduce a shuffled file order. Supplying a seed enables shuffle.
40 seed: ?u64 = null,
3641 /// Optional output supplied by a CLI host. Embedded/test callers default
3742 /// to stderr so they do not interfere with Zig's stdout test protocol.
3843 reporter_writer: ?*std.Io.Writer = null,
@@ -55,6 +60,11 @@ pub fn runDiscoveredTests(
5560
5661 var plan = try discoveryPlan(allocator, discovered, options.shard);
5762 defer plan.deinit();
63 const random_seed = random_order.resolveSeed(options.shuffle, options.seed);
64 if (random_seed) |seed| {
65 var shuffler = random_order.Shuffler.init(seed);
66 shuffler.shuffle(pipeline.PlanItem, plan.items.items);
67 }
5868 const selected_files = plan.items.items.len;
5969 if (options.shard) |shard| {
6070 std.debug.print(
@@ -84,7 +94,9 @@ pub fn runDiscoveredTests(
8494 options.use_colors,
8595 );
8696 defer reporters.deinit();
87 const events = pipeline.EventStream{ .rep = reporters.selected() };
97 const selected_reporter = reporters.selected();
98 selected_reporter.random_seed = random_seed;
99 const events = pipeline.EventStream{ .rep = selected_reporter };
88100 const policy = pipeline.ExecutionPolicy{
89101 .bail = options.bail,
90102 .filter = options.filter,
@@ -92,6 +104,7 @@ pub fn runDiscoveredTests(
92104 };
93105 var results = reporter.TestResults.init(allocator);
94106 defer results.deinit();
107 results.random_seed = random_seed;
95108 var executed_cases: std.ArrayList(suite.TestCase) = .empty;
96109 defer {
97110 for (executed_cases.items) |*test_case| test_case.deinit(allocator);
@@ -102,7 +115,7 @@ pub fn runDiscoveredTests(
102115
103116 // Notify UI of run start
104117 if (options.ui_server) |server| {
105 try server.broadcastEvent(allocator, .{ .run_start = .{ .total = selected_files } });
118 try server.broadcastEvent(allocator, .{ .run_start = .{ .total = selected_files, .random_seed = random_seed } });
106119 }
107120
108121 var files_run: usize = 0;
@@ -241,6 +254,7 @@ pub fn runDiscoveredTests(
241254 .failed = results.failed,
242255 .skipped = results.skipped,
243256 .duration_ns = results.total_time_ns,
257 .random_seed = results.random_seed,
244258 } });
245259 }
246260
@@ -355,6 +369,8 @@ test "LoaderOptions default values" {
355369 try std.testing.expectEqual(@as(?sharding.ShardOptions, null), options.shard);
356370 try std.testing.expectEqual(reporter.ReporterType.spec, options.reporter_type);
357371 try std.testing.expectEqualStrings("test-results.xml", options.junit_output);
372 try std.testing.expect(!options.shuffle);
373 try std.testing.expect(options.seed == null);
358374 try std.testing.expectEqual(@as(?u64, null), options.timeout_ms);
359375 try std.testing.expectEqual(@as(usize, 0), options.retries);
360376 try std.testing.expectEqual(@as(usize, 1), options.repeat);
src/test_runner.zigmodified+133-13
Changes to src/test_runner.zig
@@ -4,6 +4,7 @@ const reporter_mod = ;
44const pipeline = ;
55const parallel = ;
66const compat = ;
7const random_order = ;
78
89pub const RunnerError = error{
910 NoTestsFound,
@@ -25,6 +26,10 @@ pub const RunnerOptions = struct {
2526 repeat: usize = 1,
2627 /// Treat tests that pass after a retry as an unsuccessful run.
2728 fail_on_flaky: bool = false,
29 /// Shuffle suites and tests. A generated seed is printed for replay.
30 shuffle: bool = false,
31 /// Reproduce a shuffled run. Supplying a seed implicitly enables shuffle.
32 seed: ?u64 = null,
2833 /// Optional output supplied by a CLI host. Embedded/test callers default
2934 /// to stderr so they do not interfere with Zig's stdout test protocol.
3035 reporter_writer: ?*std.Io.Writer = null,
@@ -71,6 +76,9 @@ pub const TestRunner = struct {
7176 );
7277 defer reporters.deinit();
7378 const current_reporter = reporters.selected();
79 const random_seed = random_order.resolveSeed(self.options.shuffle, self.options.seed);
80 current_reporter.random_seed = random_seed;
81 self.results.random_seed = random_seed;
7482 const events = pipeline.EventStream{ .rep = current_reporter };
7583
7684 var plan = try pipeline.TestPlan.fromRegistry(self.allocator, self.registry);
@@ -87,6 +95,7 @@ pub const TestRunner = struct {
8795 .n_jobs = self.options.n_jobs,
8896 .filter = self.options.filter,
8997 .timeout_ms = self.options.timeout_ms,
98 .random_seed = random_seed,
9099 };
91100
92101 const all_passed = try parallel.runTestsParallel(
@@ -106,9 +115,19 @@ pub const TestRunner = struct {
106115 }
107116
108117 // Sequential execution (original behavior)
118 var shuffler: random_order.Shuffler = undefined;
119 const shuffler_ptr: ?*random_order.Shuffler = if (random_seed) |seed| blk: {
120 shuffler = .init(seed);
121 break :blk &shuffler;
122 } else null;
123 var root_suites: std.ArrayList(*suite.TestSuite) = .empty;
124 defer root_suites.deinit(self.allocator);
125 try root_suites.appendSlice(self.allocator, self.registry.root_suites.items);
126 if (shuffler_ptr) |randomizer| randomizer.shuffle(*suite.TestSuite, root_suites.items);
127
109128 try events.emit(.{ .run_started = total_tests });
110 for (self.registry.root_suites.items) |test_suite| {
111 try self.runSuite(test_suite, events);
129 for (root_suites.items) |test_suite| {
130 try self.runSuite(test_suite, events, shuffler_ptr);
112131 if (self.policy().shouldStop(&self.results)) {
113132 break;
114133 }
@@ -130,15 +149,20 @@ pub const TestRunner = struct {
130149 }
131150
132151 /// Run a single test suite
133 fn runSuite(self: *Self, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void {
152 fn runSuite(
153 self: *Self,
154 test_suite: *suite.TestSuite,
155 events: pipeline.EventStream,
156 shuffler: ?*random_order.Shuffler,
157 ) !void {
134158 // Skip if marked as skip or if has_only and this isn't marked as only
135159 if (test_suite.shouldSkip()) {
136 try self.skipAllTests(test_suite, events);
160 try self.skipAllTests(test_suite, events, shuffler);
137161 return;
138162 }
139163
140164 if (self.registry.has_only and !test_suite.hasOnly()) {
141 try self.skipAllTests(test_suite, events);
165 try self.skipAllTests(test_suite, events, shuffler);
142166 return;
143167 }
144168
@@ -148,13 +172,17 @@ pub const TestRunner = struct {
148172 // Run beforeAll hooks
149173 test_suite.runBeforeAllHooks(self.allocator) catch |err| {
150174 std.debug.print("beforeAll hook failed: {any}\n", .{err});
151 try self.skipAllTests(test_suite, events);
175 try self.skipAllTests(test_suite, events, shuffler);
152176 try events.emit(.{ .suite_finished = test_suite.name });
153177 return;
154178 };
155179
156180 // Run tests in this suite
157 for (test_suite.tests.items) |*test_case| {
181 var tests: std.ArrayList(*suite.TestCase) = .empty;
182 defer tests.deinit(self.allocator);
183 for (test_suite.tests.items) |*test_case| try tests.append(self.allocator, test_case);
184 if (shuffler) |randomizer| randomizer.shuffle(*suite.TestCase, tests.items);
185 for (tests.items) |test_case| {
158186 if (test_case.skip or (self.registry.has_only and !test_case.only)) {
159187 test_case.status = .skipped;
160188 try events.emit(.{ .test_finished = test_case });
@@ -178,8 +206,12 @@ pub const TestRunner = struct {
178206 }
179207
180208 // Run nested suites
181 for (test_suite.suites.items) |nested_suite| {
182 try self.runSuite(nested_suite, events);
209 var nested_suites: std.ArrayList(*suite.TestSuite) = .empty;
210 defer nested_suites.deinit(self.allocator);
211 try nested_suites.appendSlice(self.allocator, test_suite.suites.items);
212 if (shuffler) |randomizer| randomizer.shuffle(*suite.TestSuite, nested_suites.items);
213 for (nested_suites.items) |nested_suite| {
214 try self.runSuite(nested_suite, events, shuffler);
183215 if (self.policy().shouldStop(&self.results)) {
184216 break;
185217 }
@@ -298,15 +330,28 @@ pub const TestRunner = struct {
298330 }
299331
300332 /// Skip all tests in a suite
301 fn skipAllTests(self: *Self, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void {
302 for (test_suite.tests.items) |*test_case| {
333 fn skipAllTests(
334 self: *Self,
335 test_suite: *suite.TestSuite,
336 events: pipeline.EventStream,
337 shuffler: ?*random_order.Shuffler,
338 ) !void {
339 var tests: std.ArrayList(*suite.TestCase) = .empty;
340 defer tests.deinit(self.allocator);
341 for (test_suite.tests.items) |*test_case| try tests.append(self.allocator, test_case);
342 if (shuffler) |randomizer| randomizer.shuffle(*suite.TestCase, tests.items);
343 for (tests.items) |test_case| {
303344 test_case.status = .skipped;
304345 try self.results.addTest(test_case);
305346 try events.emit(.{ .test_finished = test_case });
306347 }
307348
308 for (test_suite.suites.items) |nested_suite| {
309 try self.skipAllTests(nested_suite, events);
349 var nested_suites: std.ArrayList(*suite.TestSuite) = .empty;
350 defer nested_suites.deinit(self.allocator);
351 try nested_suites.appendSlice(self.allocator, test_suite.suites.items);
352 if (shuffler) |randomizer| randomizer.shuffle(*suite.TestSuite, nested_suites.items);
353 for (nested_suites.items) |nested_suite| {
354 try self.skipAllTests(nested_suite, events, shuffler);
310355 }
311356 }
312357
@@ -355,6 +400,8 @@ test "RunnerOptions default values" {
355400 try std.testing.expectEqual(@as(usize, 0), options.retries);
356401 try std.testing.expectEqual(@as(usize, 1), options.repeat);
357402 try std.testing.expect(!options.fail_on_flaky);
403 try std.testing.expect(!options.shuffle);
404 try std.testing.expect(options.seed == null);
358405}
359406
360407test "RunnerOptions custom values" {
@@ -376,6 +423,37 @@ var failing_fixture_runs: usize = 0;
376423var skipped_by_bail_runs: usize = 0;
377424var repeated_fixture_runs: usize = 0;
378425var timeout_fixture_runs: usize = 0;
426var randomized_log: [6]u8 = undefined;
427var randomized_log_len: usize = 0;
428
429fn recordRandomized(id: u8) void {
430 randomized_log[randomized_log_len] = id;
431 randomized_log_len += 1;
432}
433
434fn randomizedOne(_: std.mem.Allocator) !void {
435 recordRandomized(1);
436}
437
438fn randomizedTwo(_: std.mem.Allocator) !void {
439 recordRandomized(2);
440}
441
442fn randomizedThree(_: std.mem.Allocator) !void {
443 recordRandomized(3);
444}
445
446fn randomizedFour(_: std.mem.Allocator) !void {
447 recordRandomized(4);
448}
449
450fn randomizedFive(_: std.mem.Allocator) !void {
451 recordRandomized(5);
452}
453
454fn randomizedSix(_: std.mem.Allocator) !void {
455 recordRandomized(6);
456}
379457
380458fn flakyFixture(_: std.mem.Allocator) !void {
381459 flaky_fixture_runs += 1;
@@ -391,6 +469,48 @@ fn skippedByBailFixture(_: std.mem.Allocator) !void {
391469 skipped_by_bail_runs += 1;
392470}
393471
472test "sequential order is reproducible with a fixed seed" {
473 const allocator = std.testing.allocator;
474 var registry = suite.TestRegistry.init(allocator);
475 defer registry.deinit();
476 const test_suite = try suite.TestSuite.init(allocator, "randomized");
477 try test_suite.addTest(suite.TestCase.init("one", randomizedOne));
478 try test_suite.addTest(suite.TestCase.init("two", randomizedTwo));
479 try test_suite.addTest(suite.TestCase.init("three", randomizedThree));
480 try test_suite.addTest(suite.TestCase.init("four", randomizedFour));
481 try test_suite.addTest(suite.TestCase.init("five", randomizedFive));
482 try test_suite.addTest(suite.TestCase.init("six", randomizedSix));
483 try registry.registerSuite(test_suite);
484
485 var first_output: [4096]u8 = undefined;
486 var first_writer: std.Io.Writer = .fixed(&first_output);
487 randomized_log_len = 0;
488 var first_runner = TestRunner.init(allocator, &registry, .{
489 .seed = 112358,
490 .use_colors = false,
491 .reporter_writer = &first_writer,
492 });
493 defer first_runner.deinit();
494 try std.testing.expect(try first_runner.run());
495 const first_order = randomized_log;
496
497 var second_output: [4096]u8 = undefined;
498 var second_writer: std.Io.Writer = .fixed(&second_output);
499 randomized_log_len = 0;
500 var second_runner = TestRunner.init(allocator, &registry, .{
501 .seed = 112358,
502 .use_colors = false,
503 .reporter_writer = &second_writer,
504 });
505 defer second_runner.deinit();
506 try std.testing.expect(try second_runner.run());
507
508 try std.testing.expectEqual(@as(usize, randomized_log.len), randomized_log_len);
509 try std.testing.expectEqualSlices(u8, &first_order, &randomized_log);
510 try std.testing.expectEqual(@as(?u64, 112358), first_runner.results.random_seed);
511 try std.testing.expectEqual(@as(?u64, 112358), second_runner.results.random_seed);
512}
513
394514fn repeatedFixture(_: std.mem.Allocator) !void {
395515 repeated_fixture_runs += 1;
396516}
src/ui_server.zigmodified+7-2
Changes to src/ui_server.zig
@@ -321,7 +321,10 @@ pub const UIReporter = struct {
321321
322322 fn onRunStart(reporter: *reporter_mod.Reporter, total: usize) !void {
323323 const self: *Self = @fieldParentPtr("reporter", reporter);
324 try self.server.broadcastEvent(reporter.allocator, .{ .run_start = .{ .total = total } });
324 try self.server.broadcastEvent(reporter.allocator, .{ .run_start = .{
325 .total = total,
326 .random_seed = reporter.random_seed,
327 } });
325328 }
326329
327330 fn onRunEnd(reporter: *reporter_mod.Reporter, results: *reporter_mod.TestResults) !void {
@@ -334,6 +337,7 @@ pub const UIReporter = struct {
334337 .failed = results.failed,
335338 .skipped = results.skipped,
336339 .duration_ns = results.total_time_ns,
340 .random_seed = results.random_seed,
337341 } });
338342 }
339343
@@ -407,6 +411,7 @@ pub const MultiReporter = struct {
407411 fn onRunStart(reporter: *reporter_mod.Reporter, total: usize) !void {
408412 const self: *Self = @fieldParentPtr("reporter", reporter);
409413 for (self.reporters) |rep| {
414 rep.random_seed = reporter.random_seed;
410415 try rep.onRunStart(total);
411416 }
412417 }
@@ -536,7 +541,7 @@ test "UI server streams broadcast events over SSE" {
536541
537542 try server.broadcastEvent(std.testing.allocator, .{ .run_start = .{ .total = 2 } });
538543 try expectLine(&stream_reader.interface, "event: run_start");
539 try expectLine(&stream_reader.interface, "data: {\"protocol_version\":1,\"type\":\"run_start\",\"data\":{\"total\":2}}");
544 try expectLine(&stream_reader.interface, "data: {\"protocol_version\":1,\"type\":\"run_start\",\"data\":{\"total\":2,\"random_seed\":null}}");
540545 try expectLine(&stream_reader.interface, "");
541546}
542547
tests/fixtures/zig-test.jsonmodified+3-1
Changes to tests/fixtures/zig-test.json
@@ -6,7 +6,9 @@
66 "filter": "selected test passes",
77 "retries": 1,
88 "repeat": 2,
9 "fail_on_flaky": false
9 "fail_on_flaky": false,
10 "shuffle": true,
11 "seed": 42
1012 },
1113 "sharding": {
1214 "index": 1,
tests/reporter_test.zigmodified+33-0
Changes to tests/reporter_test.zig
@@ -56,6 +56,8 @@ test "reporter json output uses versioned event envelopes" {
5656 defer results.deinit();
5757 results.total = 1;
5858 results.passed = 1;
59 results.random_seed = 4242;
60 reporter.reporter.random_seed = 4242;
5961 var test_case = ztf.TestCase.init("passes \"quoted\"\nname", passingTest);
6062 test_case.status = .passed;
6163
@@ -73,12 +75,14 @@ test "reporter json output uses versioned event envelopes" {
7375 try std.testing.expectEqual(@as(usize, 6), events.len);
7476 try std.testing.expectEqual(@as(i64, ztf.protocol_version), events[0].object.get("protocol_version").?.integer);
7577 try std.testing.expectEqualStrings("run_start", events[0].object.get("type").?.string);
78 try std.testing.expectEqual(@as(i64, 4242), events[0].object.get("data").?.object.get("random_seed").?.integer);
7679 try std.testing.expectEqualStrings("test_start", events[2].object.get("type").?.string);
7780 const test_data = events[3].object.get("data").?.object;
7881 try std.testing.expectEqualStrings(test_case.name, test_data.get("name").?.string);
7982 try std.testing.expectEqualStrings("suite\tone", test_data.get("suite").?.string);
8083 try std.testing.expectEqualStrings("run_end", events[5].object.get("type").?.string);
8184 try std.testing.expectEqual(@as(i64, 1), events[5].object.get("data").?.object.get("passed").?.integer);
85 try std.testing.expectEqual(@as(i64, 4242), events[5].object.get("data").?.object.get("random_seed").?.integer);
8286}
8387
8488test "reporter json output includes flaky attempt history" {
@@ -140,3 +144,32 @@ test "reporter set selects the same built-ins for every executor" {
140144 try std.testing.expect(reporters.selected() == &reporters.tap.reporter);
141145 try std.testing.expect(!reporters.selected().use_colors);
142146}
147
148test "JUnit reporter records the random seed as a suite property" {
149 const allocator = std.testing.allocator;
150 var tmp = std.testing.tmpDir(.{});
151 defer tmp.cleanup();
152 const output_path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}/results.xml", .{tmp.sub_path});
153 defer allocator.free(output_path);
154
155 var reporter = ztf.JUnitReporter.init(allocator, output_path);
156 defer reporter.deinit();
157 reporter.reporter.random_seed = 73;
158 var results = ztf.TestResults.init(allocator);
159 defer results.deinit();
160 results.total = 1;
161 results.passed = 1;
162 results.random_seed = 73;
163 var test_case = ztf.TestCase.init("passes", passingTest);
164 test_case.status = .passed;
165
166 try reporter.reporter.onRunStart(1);
167 try reporter.reporter.onSuiteStart("suite");
168 try reporter.reporter.onTestEnd(&test_case);
169 try reporter.reporter.onSuiteEnd("suite");
170 try reporter.reporter.onRunEnd(&results);
171
172 const output = try ztf.compat.readFileAlloc(allocator, output_path);
173 defer allocator.free(output);
174 try std.testing.expect(std.mem.indexOf(u8, output, "<property name=\"random_seed\" value=\"73\"/>") != null);
175}