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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

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}