ReviewOS

also looking at this

zig-utils/zig-test-framework

Add retries, repeated runs, and flaky-test reporting

#25
Merged glennmichael123 wants to merge codex/issue-7-retries-repeat-flaky into main
15 files +719 -53
CHANGELOG.mdmodified+6-0
Changes to CHANGELOG.md
@@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
1717## [Unreleased]
1818
19### Added
20
21- Retry failed tests and discovered test files with complete attempt history.
22- Repeat mode for stress-running selected tests.
23- Flaky outcomes, optional fail-on-flaky exit behavior, and JSON/JUnit attempt data.
24
1925### Planned Features
2026
2127- Snapshot testing
README.mdmodified+21-1
Changes to README.md
@@ -8,6 +8,7 @@ A modern, feature-rich testing framework for Zig inspired by Jest, Vitest, and B
88- **Code Coverage** - Line, branch, and function coverage with HTML reports (via kcov/grindcov)
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
11- **Retries & Repeats** - Re-run failures, stress-run selected tests, and report flaky outcomes
1112- **Familiar API** - Describe/it syntax similar to Jest and Vitest
1213- **Rich Assertions** - Comprehensive assertion library with `.expect()` and matchers
1314- **Error Assertions** - `toThrow()` and `toThrowError()` for testing error handling
@@ -98,6 +99,9 @@ This will automatically discover and run all `*.test.zig` files in the `tests` d
9899- `--reporter <name>` - Use spec, dot, JSON, TAP, or JUnit output
99100- `--junit-output <file>` - Set the JUnit XML path
100101- `--timeout <ms>` - Mark files that exceed the global time budget as failed
102- `--retry <N>` - Retry a failed test or discovered test file up to N times
103- `--repeat <N>` - Require N successful repetitions of every selected test
104- `--fail-on-flaky` - Return a failing exit status when a retry recovers a failure
101105- `--shard-index <N>` - Run one one-based file shard
102106- `--shard-count <N>` - Set the total number of file shards
103107- `--no-color` - Disable color in child Zig test processes
@@ -125,6 +129,12 @@ zig-test --test-dir tests --no-recursive
125129# Stop on first failure
126130zig-test --test-dir tests --bail
127131
132# Retry failures twice and fail CI if any test is flaky
133zig-test --test-dir tests --retry 2 --fail-on-flaky
134
135# Stress-run matching tests 100 times
136zig-test --test-dir tests --filter parser --repeat 100
137
128138# Run the second of four deterministic file shards
129139zig-test --test-dir tests --shard-index 2 --shard-count 4
130140```
@@ -168,7 +178,10 @@ precedence over configured values.
168178 "test_dir": "tests",
169179 "pattern": "*.test.zig",
170180 "recursive": true,
171 "filter": "database"
181 "filter": "database",
182 "retries": 2,
183 "repeat": 10,
184 "fail_on_flaky": true
172185 },
173186 "sharding": {
174187 "index": 1,
@@ -185,6 +198,9 @@ The accepted top-level sections are `test`, `sharding`, `parallel`, `reporter`,
185198`snapshot`, `watch`, `memory`, `ui`, and `coverage`. Options retain the same execution-mode
186199limitations as their CLI equivalents; unsupported combinations fail explicitly.
187200
201See [Retries, repeats, and flaky tests](docs/retries-and-repeats.md) for attempt
202semantics, exit codes, reporter output, and programmatic configuration.
203
188204### Parallel execution semantics
189205
190206Programmatic runners can enable bounded execution with `RunnerOptions.parallel`
@@ -642,6 +658,9 @@ zig-test --no-color
642658| `--reporter <name>` | | Set reporter (spec, dot, json, tap, junit) |
643659| `--junit-output <file>` | | Set the JUnit XML output path |
644660| `--timeout <ms>` | | Set the global test timeout |
661| `--retry <N>` | `--retries <N>` | Retry each failed test up to N times |
662| `--repeat <N>` | | Require N successful runs of each selected test |
663| `--fail-on-flaky` | | Fail the run when a retry recovers a failure |
645664| `--verbose` | | Enable verbose output |
646665| `--quiet` | `-q` | Minimal output |
647666| `--no-color` | | Disable colored output |
@@ -921,6 +940,7 @@ MIT License - see [LICENSE](LICENSE) for details.
921940- [x] Memory profiling
922941- [x] Strict JSON configuration files
923942- [x] Parallel test execution
943- [x] Retries, repeated runs, and flaky-test reporting
924944- [ ] Parameterized tests (it.each)
925945- [ ] Property-based testing
926946- [ ] IDE integration
build.zigmodified+15-0
Changes to build.zig
@@ -293,6 +293,21 @@ pub fn build(b: *std.Build) void {
293293 );
294294 test_step.dependOn(&run_discovery_tap.step);
295295
296 const run_discovery_repeat = b.addRunArtifact(exe);
297 run_discovery_repeat.addArgs(&.{
298 "--test-dir",
299 "tests",
300 "--pattern",
301 "sample.test.zig",
302 "--retry",
303 "1",
304 "--repeat",
305 "2",
306 "--fail-on-flaky",
307 "--no-color",
308 });
309 test_step.dependOn(&run_discovery_repeat.step);
310
296311 const run_version = b.addRunArtifact(exe);
297312 run_version.addArg("--version");
298313 run_version.expectStdErrEqual(b.fmt("Zig Test Framework v{s}\n", .{package_version}));
docs/execution-pipeline.mdmodified+11-6
Changes to docs/execution-pipeline.md
@@ -26,10 +26,14 @@ reporters, including the configured JUnit output path.
2626- Registered tests match filters directly by test name. Discovery forwards the
2727 same filter to `zig test`, where Zig applies it to names inside each file.
2828- Bail stops scheduling after the shared result model records a failure. Work
29 already started by a parallel batch cannot be cancelled.
29 already started by a parallel batch cannot be cancelled. With retries, bail
30 is evaluated only after the retry budget is exhausted.
3031- A global timeout compares elapsed execution time with the same policy in both
31 executors. It marks an over-budget result failed after control returns; it
32 does not yet terminate an in-process function or external child process.
32 executors on every attempt. It marks an over-budget attempt failed after
33 control returns; it does not yet terminate an in-process function or external
34 child process.
35- Every logical result owns ordered attempt history. A failure followed by a
36 successful retry is classified as `flaky`, distinct from an ordinary pass.
3337- Summaries always come from `TestResults`, rather than executor-local counters.
3438
3539## Extension points
@@ -48,10 +52,11 @@ depend on one serialization format.
4852Existing programmatic APIs remain source compatible:
4953
5054- `TestRunner.init`, `runTests`, and `runTestsWithOptions` are unchanged.
51- `RunnerOptions` only gains optional `junit_output`, `timeout_ms`, and
52 `reporter_writer` fields.
55- `RunnerOptions` gains optional reporter, timeout, retry, repeat, and flaky-exit
56 fields with backward-compatible defaults.
5357- `runDiscoveredTests` and `LoaderOptions` remain available; optional reporter,
54 JUnit path, timeout, and writer fields default to the previous spec behavior.
58 JUnit path, timeout, retry, repeat, flaky-exit, and writer fields default to
59 the previous spec behavior.
5560
5661CLI hosts can inject a stdout writer with `reporter_writer`. Embedded callers
5762may leave it unset; reporter output then uses stderr so it cannot corrupt Zig's
docs/retries-and-repeats.mdadded+92-0
Changes to docs/retries-and-repeats.md
@@ -0,0 +1,92 @@
1# Retries, repeats, and flaky tests
2
3The runner records each execution of a logical test as an attempt. An attempt
4contains its one-based number, repetition number, status, duration, and error
5message. Registered tests and discovered test files use the same model.
6
7## Command-line use
8
9```bash
10# One initial attempt plus as many as two retries after failure
11zig-test --test-dir tests --retry 2
12
13# Require every selected test to pass 100 times
14zig-test --test-dir tests --filter parser --repeat 100
15
16# Make a recovered failure fail the overall run in CI
17zig-test --test-dir tests --retry 2 --fail-on-flaky
18```
19
20`--retries` is an alias for `--retry`. A retry count is the number of
21*additional* attempts after a failure. `--repeat N` requires N successful
22repetitions; each repetition receives its own retry budget. Attempt history is
23ordered across all repetitions.
24
25Discovery operates at test-file granularity because each file is an external
26`zig test` process. Programmatic mode operates at individual-test granularity.
27
28## Outcomes and exit status
29
30- `passed`: every attempt passed without a retry.
31- `flaky`: at least one attempt failed, but a retry recovered and all required
32 repetitions eventually passed.
33- `failed`: one repetition exhausted its retry budget.
34- `skipped`: the test was not selected for execution.
35
36Failed tests always make the process exit unsuccessfully. Flaky tests succeed
37by default so retries can recover an intermittent failure. `--fail-on-flaky`
38changes that policy and returns an unsuccessful exit status when any test is
39flaky.
40
41`--bail` is evaluated after retries, so one failed attempt does not stop the
42run. The runner stops scheduling only after a logical test exhausts its retry
43budget. The timeout is evaluated independently for every attempt; a timed-out
44attempt may be retried.
45
46When retries or repeats are configured for registered tests, execution is
47sequential even if parallel mode was requested. This preserves deterministic
48attempt ordering and hook behavior.
49
50## Programmatic configuration
51
52Set defaults for a registered-test run with `RunnerOptions`:
53
54```zig
55const options = ztf.RunnerOptions{
56 .retries = 2,
57 .repeat = 10,
58 .fail_on_flaky = true,
59};
60const passed = try ztf.runTestsWithOptions(allocator, registry, options);
61```
62
63An individual registered test can override the run-level retry count:
64
65```zig
66try suite.addTest(
67 ztf.TestCase.init("eventually consistent", testEventuallyConsistent)
68 .withRetries(5),
69);
70```
71
72`LoaderOptions` exposes the same `retries`, `repeat`, and `fail_on_flaky`
73fields for callers that run discovered files directly. Strict JSON
74configuration uses the corresponding fields in the `test` object:
75
76```json
77{
78 "test": {
79 "retries": 2,
80 "repeat": 10,
81 "fail_on_flaky": true
82 }
83}
84```
85
86## Reporter data
87
88The spec and dot reporters show flaky results separately from passes. JSON test
89objects include an `attempts` array with `number`, `repetition`, `status`, and
90`durationNs` fields plus an optional `error`. The JSON summary includes a
91`flaky` count. JUnit output includes the flaky count on `<testsuites>` and emits
92ordered attempt data in each retried test case's `<system-out>` element.
src/cli.zigmodified+70-0
Changes to src/cli.zig
@@ -45,6 +45,10 @@ pub const CLIOptions = struct {
4545 junit_output: ?[]const u8 = null,
4646 // Timeout options
4747 timeout: ?u64 = null, // Global timeout in milliseconds
48 // Retry and repetition options
49 retries: usize = 0,
50 repeat: usize = 1,
51 fail_on_flaky: bool = false,
4852};
4953
5054pub const version = build_options.version;
@@ -75,6 +79,9 @@ pub const CLI = struct {
7579 self.options.no_recursive = !config.test_options.recursive;
7680 self.options.filter = config.test_options.filter;
7781 self.options.timeout = config.test_options.timeout;
82 self.options.retries = config.test_options.retries;
83 self.options.repeat = config.test_options.repeat;
84 self.options.fail_on_flaky = config.test_options.fail_on_flaky;
7885 self.options.shard_index = config.sharding.index;
7986 self.options.shard_count = config.sharding.count;
8087 self.options.parallel = config.parallel.enabled;
@@ -107,6 +114,32 @@ pub const CLI = struct {
107114 self.options.version = true;
108115 } else if (std.mem.eql(u8, arg, "--bail") or std.mem.eql(u8, arg, "-b")) {
109116 self.options.bail = true;
117 } else if (std.mem.eql(u8, arg, "--retry") or std.mem.eql(u8, arg, "--retries")) {
118 if (i + 1 >= args.len) {
119 std.debug.print("Error: {s} requires a value\n", .{arg});
120 return CLIError.MissingValue;
121 }
122 i += 1;
123 self.options.retries = std.fmt.parseInt(usize, args[i], 10) catch {
124 std.debug.print("Error: {s} must be a valid number\n", .{arg});
125 return CLIError.InvalidArgument;
126 };
127 } else if (std.mem.eql(u8, arg, "--repeat")) {
128 if (i + 1 >= args.len) {
129 std.debug.print("Error: --repeat requires a value\n", .{});
130 return CLIError.MissingValue;
131 }
132 i += 1;
133 self.options.repeat = std.fmt.parseInt(usize, args[i], 10) catch {
134 std.debug.print("Error: --repeat must be a valid positive number\n", .{});
135 return CLIError.InvalidArgument;
136 };
137 if (self.options.repeat == 0) {
138 std.debug.print("Error: --repeat must be greater than zero\n", .{});
139 return CLIError.InvalidArgument;
140 }
141 } else if (std.mem.eql(u8, arg, "--fail-on-flaky")) {
142 self.options.fail_on_flaky = true;
110143 } else if (std.mem.eql(u8, arg, "--verbose")) {
111144 self.options.verbose = true;
112145 } else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q")) {
@@ -351,6 +384,9 @@ pub const CLI = struct {
351384 \\ -h, --help Show this help message
352385 \\ -v, --version Show version information
353386 \\ -b, --bail Stop test execution on first failure
387 \\ --retry <N> Retry each failed test up to N times
388 \\ --repeat <N> Require N successful runs of each selected test
389 \\ --fail-on-flaky Exit unsuccessfully when a retry recovers a failure
354390 \\ --filter <pattern> Run only tests matching pattern
355391 \\ --grep <pattern> Same as --filter (alias)
356392 \\ --reporter <name> Set reporter type (spec, dot, json, tap, junit)
@@ -414,6 +450,8 @@ pub const CLI = struct {
414450 \\ zig-test --update-snapshots Update all snapshots
415451 \\ zig-test --watch Run in watch mode
416452 \\ zig-test --timeout 10000 Set 10s timeout for all tests
453 \\ zig-test --retry 2 --fail-on-flaky Detect and reject flaky tests
454 \\ zig-test --repeat 100 --filter parser Stress-run selected tests
417455 \\ zig-test --profile-memory --fail-on-leak
418456 \\ zig-test --parallel --jobs 4 Run with 4 parallel workers
419457 \\ zig-test --config zig-test.json Load config from file
@@ -441,6 +479,9 @@ pub const CLI = struct {
441479 .n_jobs = self.options.jobs,
442480 .junit_output = self.options.junit_output orelse "test-results.xml",
443481 .timeout_ms = self.options.timeout,
482 .retries = self.options.retries,
483 .repeat = self.options.repeat,
484 .fail_on_flaky = self.options.fail_on_flaky,
444485 };
445486 }
446487};
@@ -662,6 +703,35 @@ test "CLI parse timeout" {
662703 try std.testing.expectEqual(@as(?u64, 5000), cli.options.timeout);
663704}
664705
706test "CLI parses retry repeat and flaky exit policy" {
707 var cli = CLI.init(std.testing.allocator);
708 const args = [_][]const u8{
709 "zig-test",
710 "--retry",
711 "2",
712 "--repeat",
713 "5",
714 "--fail-on-flaky",
715 };
716 try cli.parse(&args);
717 try std.testing.expectEqual(@as(usize, 2), cli.options.retries);
718 try std.testing.expectEqual(@as(usize, 5), cli.options.repeat);
719 try std.testing.expect(cli.options.fail_on_flaky);
720
721 const runner_options = cli.toRunnerOptions();
722 try std.testing.expectEqual(@as(usize, 2), runner_options.retries);
723 try std.testing.expectEqual(@as(usize, 5), runner_options.repeat);
724 try std.testing.expect(runner_options.fail_on_flaky);
725}
726
727test "CLI rejects a zero repeat count" {
728 var cli = CLI.init(std.testing.allocator);
729 try std.testing.expectError(
730 CLIError.InvalidArgument,
731 cli.parse(&.{ "zig-test", "--repeat", "0" }),
732 );
733}
734
665735test "CLI rejects invalid coverage tool" {
666736 var cli = CLI.init(std.testing.allocator);
667737 const args = [_][]const u8{ "zig-test", "--coverage-tool", "unknown" };
src/config.zigmodified+14-0
Changes to src/config.zig
@@ -29,6 +29,9 @@ pub const TestOptions = struct {
2929 recursive: bool = true,
3030 filter: ?[]const u8 = null,
3131 timeout: ?u64 = null,
32 retries: usize = 0,
33 repeat: usize = 1,
34 fail_on_flaky: bool = false,
3235};
3336
3437pub const ParallelOptions = struct {
@@ -86,6 +89,7 @@ pub const ConfigError = error{
8689 InvalidJobs,
8790 InvalidPort,
8891 InvalidTimeout,
92 InvalidRepeat,
8993 InvalidDebounce,
9094 InvalidShard,
9195};
@@ -183,6 +187,7 @@ fn validate(config: TestConfig) !void {
183187 if (config.test_options.timeout) |timeout| {
184188 if (timeout == 0) return ConfigError.InvalidTimeout;
185189 }
190 if (config.test_options.repeat == 0) return ConfigError.InvalidRepeat;
186191 if (config.watch.debounce_ms == 0) return ConfigError.InvalidDebounce;
187192 const has_shard_index = config.sharding.index != null;
188193 const has_shard_count = config.sharding.count != null;
@@ -199,6 +204,9 @@ test "TestConfig defaults match CLI discovery defaults" {
199204 try std.testing.expectEqualStrings(".", config.test_options.test_dir);
200205 try std.testing.expect(config.test_options.recursive);
201206 try std.testing.expect(config.test_options.timeout == null);
207 try std.testing.expectEqual(@as(usize, 0), config.test_options.retries);
208 try std.testing.expectEqual(@as(usize, 1), config.test_options.repeat);
209 try std.testing.expect(!config.test_options.fail_on_flaky);
202210 try std.testing.expect(config.sharding.index == null);
203211 try std.testing.expect(config.sharding.count == null);
204212 try std.testing.expect(!config.parallel.enabled);
@@ -219,6 +227,9 @@ test "configuration validation rejects invalid runtime values" {
219227 invalid.parallel.jobs = 0;
220228 try std.testing.expectError(ConfigError.InvalidJobs, validate(invalid));
221229 invalid.parallel.jobs = null;
230 invalid.test_options.repeat = 0;
231 try std.testing.expectError(ConfigError.InvalidRepeat, validate(invalid));
232 invalid.test_options.repeat = 1;
222233 invalid.sharding.index = 2;
223234 try std.testing.expectError(ConfigError.InvalidShard, validate(invalid));
224235 invalid.sharding.count = 1;
@@ -233,6 +244,9 @@ test "ConfigLoader loads supported JSON and rejects unknown keys" {
233244 try std.testing.expectEqualStrings("tests/fixtures", config.test_options.test_dir);
234245 try std.testing.expectEqualStrings("selected test passes", config.test_options.filter.?);
235246 try std.testing.expect(!config.test_options.recursive);
247 try std.testing.expectEqual(@as(usize, 1), config.test_options.retries);
248 try std.testing.expectEqual(@as(usize, 2), config.test_options.repeat);
249 try std.testing.expect(!config.test_options.fail_on_flaky);
236250 try std.testing.expectEqual(@as(?usize, 1), config.sharding.index);
237251 try std.testing.expectEqual(@as(?usize, 1), config.sharding.count);
238252
src/lib.zigmodified+1-0
Changes to src/lib.zig
@@ -60,6 +60,7 @@ pub const describeTimeout = suite.describeTimeout;
6060pub const TestSuite = suite.TestSuite;
6161pub const TestCase = suite.TestCase;
6262pub const TestStatus = suite.TestStatus;
63pub const TestAttempt = suite.TestAttempt;
6364pub const TestRegistry = suite.TestRegistry;
6465pub const TestFn = suite.TestFn;
6566pub const HookFn = suite.HookFn;
src/main.zigmodified+6-0
Changes to src/main.zig
@@ -221,6 +221,9 @@ pub fn main(init: std.process.Init.Minimal) !void {
221221 .reporter_type = cli_parser.options.reporter,
222222 .junit_output = cli_parser.options.junit_output orelse "test-results.xml",
223223 .timeout_ms = cli_parser.options.timeout,
224 .retries = cli_parser.options.retries,
225 .repeat = cli_parser.options.repeat,
226 .fail_on_flaky = cli_parser.options.fail_on_flaky,
224227 .reporter_writer = &stdout_writer.interface,
225228 .coverage_options = cov_opts,
226229 .ui_server = if (ui_server) |*server| server else null,
@@ -264,6 +267,9 @@ pub fn main(init: std.process.Init.Minimal) !void {
264267 .reporter_type = cli_parser.options.reporter,
265268 .junit_output = cli_parser.options.junit_output orelse "test-results.xml",
266269 .timeout_ms = cli_parser.options.timeout,
270 .retries = cli_parser.options.retries,
271 .repeat = cli_parser.options.repeat,
272 .fail_on_flaky = cli_parser.options.fail_on_flaky,
267273 .reporter_writer = &stdout_writer.interface,
268274 .coverage_options = cov_opts,
269275 .ui_server = if (ui_server) |*server| server else null,
src/reporter.zigmodified+127-5
Changes to src/reporter.zig
@@ -71,21 +71,25 @@ pub const Reporter = struct {
7171pub const TestResults = struct {
7272 total: usize = 0,
7373 passed: usize = 0,
74 flaky: usize = 0,
7475 failed: usize = 0,
7576 skipped: usize = 0,
7677 total_time_ns: u64 = 0,
7778 failed_tests: std.ArrayList(suite.TestCase),
79 flaky_tests: std.ArrayList(suite.TestCase),
7880 allocator: std.mem.Allocator,
7981
8082 pub fn init(allocator: std.mem.Allocator) TestResults {
8183 return TestResults{
8284 .failed_tests = .empty,
85 .flaky_tests = .empty,
8386 .allocator = allocator,
8487 };
8588 }
8689
8790 pub fn deinit(self: *TestResults) void {
8891 self.failed_tests.deinit(self.allocator);
92 self.flaky_tests.deinit(self.allocator);
8993 }
9094
9195 pub fn addTest(self: *TestResults, test_case: *const suite.TestCase) !void {
@@ -94,6 +98,10 @@ pub const TestResults = struct {
9498
9599 switch (test_case.status) {
96100 .passed => self.passed += 1,
101 .flaky => {
102 self.flaky += 1;
103 try self.flaky_tests.append(self.allocator, test_case.*);
104 },
97105 .failed => {
98106 self.failed += 1;
99107 try self.failed_tests.append(self.allocator, test_case.*);
@@ -188,6 +196,9 @@ pub const SpecReporter = struct {
188196 try s.output().print("{s}Test Summary:{s}\n", .{ Colors.bold, Colors.reset });
189197 try s.output().print(" Total: {d}\n", .{results.total});
190198 try s.output().print(" {s}Passed: {d}{s}\n", .{ Colors.green, results.passed, Colors.reset });
199 if (results.flaky > 0) {
200 try s.output().print(" {s}Flaky: {d}{s}\n", .{ Colors.yellow, results.flaky, Colors.reset });
201 }
191202 if (results.failed > 0) {
192203 try s.output().print(" {s}Failed: {d}{s}\n", .{ Colors.red, results.failed, Colors.reset });
193204 }
@@ -199,6 +210,9 @@ pub const SpecReporter = struct {
199210 try s.output().print("Test Summary:\n", .{});
200211 try s.output().print(" Total: {d}\n", .{results.total});
201212 try s.output().print(" Passed: {d}\n", .{results.passed});
213 if (results.flaky > 0) {
214 try s.output().print(" Flaky: {d}\n", .{results.flaky});
215 }
202216 if (results.failed > 0) {
203217 try s.output().print(" Failed: {d}\n", .{results.failed});
204218 }
@@ -265,6 +279,25 @@ pub const SpecReporter = struct {
265279 try s.output().print("✓ {s} ({d:.2}ms)\n", .{ test_case.name, time_ms });
266280 }
267281 },
282 .flaky => {
283 if (reporter.use_colors) {
284 try s.output().print("{s}~{s} {s} {s}({d} attempts, {d:.2}ms){s}\n", .{
285 Colors.yellow,
286 Colors.reset,
287 test_case.name,
288 Colors.gray,
289 test_case.attempts.items.len,
290 time_ms,
291 Colors.reset,
292 });
293 } else {
294 try s.output().print("~ {s} (flaky after {d} attempts, {d:.2}ms)\n", .{
295 test_case.name,
296 test_case.attempts.items.len,
297 time_ms,
298 });
299 }
300 },
268301 .failed => {
269302 if (reporter.use_colors) {
270303 try s.output().print("{s}✗{s} {s} {s}({d:.2}ms){s}\n", .{
@@ -353,11 +386,13 @@ pub const DotReporter = struct {
353386
354387 if (reporter.use_colors) {
355388 try s.output().print("{s}Passed: {d}{s}, ", .{ Colors.green, results.passed, Colors.reset });
389 try s.output().print("{s}Flaky: {d}{s}, ", .{ Colors.yellow, results.flaky, Colors.reset });
356390 try s.output().print("{s}Failed: {d}{s}, ", .{ Colors.red, results.failed, Colors.reset });
357391 try s.output().print("Total: {d} ({d:.2}ms)\n", .{ results.total, total_time_ms });
358392 } else {
359 try s.output().print("Passed: {d}, Failed: {d}, Total: {d} ({d:.2}ms)\n", .{
393 try s.output().print("Passed: {d}, Flaky: {d}, Failed: {d}, Total: {d} ({d:.2}ms)\n", .{
360394 results.passed,
395 results.flaky,
361396 results.failed,
362397 results.total,
363398 total_time_ms,
@@ -391,6 +426,13 @@ pub const DotReporter = struct {
391426 try s.output().print(".", .{});
392427 }
393428 },
429 .flaky => {
430 if (reporter.use_colors) {
431 try s.output().print("{s}~{s}", .{ Colors.yellow, Colors.reset });
432 } else {
433 try s.output().print("~", .{});
434 }
435 },
394436 .failed => {
395437 if (reporter.use_colors) {
396438 try s.output().print("{s}F{s}", .{ Colors.red, Colors.reset });
@@ -422,6 +464,7 @@ pub const JsonReporter = struct {
422464 writer: std.Io.Writer,
423465 writer_ref: ?*std.Io.Writer = null,
424466 suites: std.ArrayList([]const u8),
467 emitted_tests: bool = false,
425468
426469 const Self = @This();
427470
@@ -472,9 +515,10 @@ pub const JsonReporter = struct {
472515 const s = self(reporter);
473516 const total_time_ms = @as(f64, @floatFromInt(results.total_time_ns)) / 1_000_000.0;
474517
475 try s.output().print("],\"summary\":}}\n", .{
518 try s.output().print("\n],\"summary\":}}\n", .{
476519 results.total,
477520 results.passed,
521 results.flaky,
478522 results.failed,
479523 results.skipped,
480524 total_time_ms,
@@ -505,11 +549,15 @@ pub const JsonReporter = struct {
505549
506550 const status_str = switch (test_case.status) {
507551 .passed => "passed",
552 .flaky => "flaky",
508553 .failed => "failed",
509554 .skipped => "skipped",
510555 else => "unknown",
511556 };
512557
558 if (s.emitted_tests) try s.output().print(",\n", .{});
559 s.emitted_tests = true;
560
513561 // Note: In a real implementation, you'd want to properly escape JSON strings
514562 try s.output().print(" ", .{});
525592 }
526593};
527594
@@ -593,6 +660,7 @@ pub const TAPReporter = struct {
593660
594661 const status = switch (test_case.status) {
595662 .passed => "ok",
663 .flaky => "ok",
596664 .failed => "not ok",
597665 .skipped => "ok",
598666 else => "not ok",
@@ -600,7 +668,9 @@ pub const TAPReporter = struct {
600668
601669 try self.output().print("{s} {d} - {s}", .{ status, self.test_count, test_case.name });
602670
603 if (test_case.status == .skipped) {
671 if (test_case.status == .flaky) {
672 try self.output().print(" # FLAKY attempts={d}\n", .{test_case.attempts.items.len});
673 } else if (test_case.status == .skipped) {
604674 try self.output().print(" # SKIP\n", .{});
605675 } else if (test_case.status == .failed and test_case.error_message != null) {
606676 try self.output().print("\n ---\n message: {s}\n ...\n", .{test_case.error_message.?});
@@ -631,6 +701,10 @@ pub const JUnitReporter = struct {
631701 if (test_case.error_message) |msg| {
632702 allocator.free(msg);
633703 }
704 for (test_case.attempts) |attempt| {
705 if (attempt.error_message) |msg| allocator.free(msg);
706 }
707 allocator.free(test_case.attempts);
634708 }
635709 self.tests.deinit(allocator);
636710 allocator.free(self.name);
@@ -642,6 +716,15 @@ pub const JUnitReporter = struct {
642716 time: f64,
643717 status: suite.TestStatus,
644718 error_message: ?[]const u8,
719 attempts: []AttemptResult,
720 };
721
722 const AttemptResult = struct {
723 number: usize,
724 repetition: usize,
725 status: suite.TestStatus,
726 duration_ns: u64,
727 error_message: ?[]const u8,
645728 };
646729
647730 pub fn init(allocator: std.mem.Allocator, output_file: []const u8) Self {
@@ -716,11 +799,27 @@ pub const JUnitReporter = struct {
716799 else
717800 null;
718801
802 const attempts = try self.allocator.alloc(AttemptResult, test_case.attempts.items.len);
803 errdefer self.allocator.free(attempts);
804 for (test_case.attempts.items, 0..) |attempt, index| {
805 attempts[index] = .{
806 .number = attempt.number,
807 .repetition = attempt.repetition,
808 .status = attempt.status,
809 .duration_ns = attempt.duration_ns,
810 .error_message = if (attempt.error_message) |message|
811 try self.allocator.dupe(u8, message)
812 else
813 null,
814 };
815 }
816
719817 const test_result = TestCaseResult{
720818 .name = try self.allocator.dupe(u8, test_case.name),
721819 .time = time_seconds,
722820 .status = test_case.status,
723821 .error_message = error_msg,
822 .attempts = attempts,
724823 };
725824
726825 try test_suite.tests.append(self.allocator, test_result);
@@ -732,10 +831,11 @@ pub const JUnitReporter = struct {
732831 defer buffer.deinit(self.allocator);
733832
734833 try buffer.appendSlice(self.allocator, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
735 try buffer.print(self.allocator, "<testsuites tests=\"{d}\" failures=\"{d}\" skipped=\"{d}\">\n", .{
834 try buffer.print(self.allocator, "<testsuites tests=\"{d}\" failures=\"{d}\" skipped=\"{d}\" flaky=\"{d}\">\n", .{
736835 results.total,
737836 results.failed,
738837 results.skipped,
838 results.flaky,
739839 });
740840
741841 for (self.suites.items) |suite_result| {
@@ -768,11 +868,16 @@ pub const JUnitReporter = struct {
768868 try buffer.print(self.allocator, " <failure message=\"{s}\"/>\n", .{
769869 test_result.error_message orelse "Test failed",
770870 });
871 try writeAttemptHistory(&buffer, self.allocator, test_result.attempts);
771872 try buffer.appendSlice(self.allocator, " </testcase>\n");
772873 } else if (test_result.status == .skipped) {
773874 try buffer.appendSlice(self.allocator, ">\n");
774875 try buffer.appendSlice(self.allocator, " <skipped/>\n");
775876 try buffer.appendSlice(self.allocator, " </testcase>\n");
877 } else if (test_result.attempts.len > 1) {
878 try buffer.appendSlice(self.allocator, ">\n");
879 try writeAttemptHistory(&buffer, self.allocator, test_result.attempts);
880 try buffer.appendSlice(self.allocator, " </testcase>\n");
776881 } else {
777882 try buffer.appendSlice(self.allocator, "/>\n");
778883 }
@@ -785,6 +890,23 @@ pub const JUnitReporter = struct {
785890
786891 try compat.writeFile(self.allocator, self.output_file, buffer.items);
787892 }
893
894 fn writeAttemptHistory(
895 buffer: *std.ArrayList(u8),
896 allocator: std.mem.Allocator,
897 attempts: []const AttemptResult,
898 ) !void {
899 try buffer.appendSlice(allocator, " <system-out>");
900 for (attempts, 0..) |attempt, index| {
901 if (index > 0) try buffer.appendSlice(allocator, "&#10;");
902 try buffer.print(
903 allocator,
904 "attempt={d} repetition={d} status={s} duration_ns={d}",
905 .{ attempt.number, attempt.repetition, @tagName(attempt.status), attempt.duration_ns },
906 );
907 }
908 try buffer.appendSlice(allocator, "</system-out>\n");
909 }
788910};
789911
790912/// Owns every built-in reporter and selects one through a shared interface.
src/suite.zigmodified+31-0
Changes to src/suite.zig
@@ -24,10 +24,21 @@ pub const TestStatus = enum {
2424 pending,
2525 running,
2626 passed,
27 flaky,
2728 failed,
2829 skipped,
2930};
3031
32/// The outcome of one execution of a test. A logical test may have several
33/// attempts when retries or repeated runs are enabled.
34pub const TestAttempt = struct {
35 number: usize,
36 repetition: usize,
37 status: TestStatus,
38 duration_ns: u64,
39 error_message: ?[]const u8 = null,
40};
41
3142/// Individual test case
3243pub const TestCase = struct {
3344 name: []const u8,
@@ -37,6 +48,9 @@ pub const TestCase = struct {
3748 status: TestStatus = .pending,
3849 error_message: ?[]const u8 = null,
3950 execution_time_ns: u64 = 0,
51 /// Override the runner retry count for this test when non-null.
52 retry_count: ?usize = null,
53 attempts: std.ArrayList(TestAttempt) = .empty,
4054 skip: bool = false,
4155 only: bool = false,
4256 file: []const u8 = "",
@@ -65,6 +79,20 @@ pub const TestCase = struct {
6579 .timeout_ms = timeout_ms,
6680 };
6781 }
82
83 /// Configure an additional-attempt count for this individual test.
84 pub fn withRetries(self: TestCase, retries: usize) TestCase {
85 var configured = self;
86 configured.retry_count = retries;
87 return configured;
88 }
89
90 pub fn deinit(self: *TestCase, allocator: std.mem.Allocator) void {
91 for (self.attempts.items) |attempt| {
92 if (attempt.error_message) |message| allocator.free(message);
93 }
94 self.attempts.deinit(allocator);
95 }
6896};
6997
7098/// Test suite (describe block)
@@ -103,6 +131,7 @@ pub const TestSuite = struct {
103131 for (self.suites.items) |suite| {
104132 suite.deinit();
105133 }
134 for (self.tests.items) |*test_case| test_case.deinit(self.allocator);
106135 self.tests.deinit(self.allocator);
107136 self.suites.deinit(self.allocator);
108137 self.before_each_hooks.deinit(self.allocator);
@@ -456,12 +485,14 @@ test "TestStatus enum values" {
456485 const pending_status = TestStatus.pending;
457486 const running_status = TestStatus.running;
458487 const passed_status = TestStatus.passed;
488 const flaky_status = TestStatus.flaky;
459489 const failed_status = TestStatus.failed;
460490 const skipped_status = TestStatus.skipped;
461491
462492 try std.testing.expect(pending_status == .pending);
463493 try std.testing.expect(running_status == .running);
464494 try std.testing.expect(passed_status == .passed);
495 try std.testing.expect(flaky_status == .flaky);
465496 try std.testing.expect(failed_status == .failed);
466497 try std.testing.expect(skipped_status == .skipped);
467498}
src/test_loader.zigmodified+63-17
Changes to src/test_loader.zig
@@ -26,6 +26,12 @@ pub const LoaderOptions = struct {
2626 junit_output: []const u8 = "test-results.xml",
2727 /// Global per-file timeout checked by the shared execution policy
2828 timeout_ms: ?u64 = null,
29 /// Additional process attempts after a failed test file.
30 retries: usize = 0,
31 /// Number of successful repetitions required for every selected file.
32 repeat: usize = 1,
33 /// Treat recovered failures as an unsuccessful run.
34 fail_on_flaky: bool = false,
2935 /// Optional output supplied by a CLI host. Embedded/test callers default
3036 /// to stderr so they do not interfere with Zig's stdout test protocol.
3137 reporter_writer: ?*std.Io.Writer = null,
@@ -85,6 +91,11 @@ pub fn runDiscoveredTests(
8591 };
8692 var results = reporter.TestResults.init(allocator);
8793 defer results.deinit();
94 var executed_cases: std.ArrayList(suite.TestCase) = .empty;
95 defer {
96 for (executed_cases.items) |*test_case| test_case.deinit(allocator);
97 executed_cases.deinit(allocator);
98 }
8899
89100 try events.emit(.{ .run_started = selected_files });
90101
@@ -120,23 +131,49 @@ pub fn runDiscoveredTests(
120131 try server.broadcast("test_start", json);
121132 }
122133
123 const start_time = compat.nanoTimestamp();
124 var result = try runTestFile(allocator, file.path, options);
125 const end_time = compat.nanoTimestamp();
126 const execution_time_ns: u64 = @intCast(end_time - start_time);
127 const timed_out = policy.timedOut(execution_time_ns);
128 if (timed_out) result = false;
129
130134 files_run += 1;
131135 var test_case = suite.TestCase.init(file.name, externalTestNoop);
132136 test_case.file = file.relative_path;
133 test_case.execution_time_ns = execution_time_ns;
134 test_case.status = if (result) .passed else .failed;
135 if (timed_out) {
136 test_case.error_message = "External test file exceeded the configured timeout";
137 } else if (!result) {
138 test_case.error_message = "External zig test process failed";
137 const repeat_count = @max(options.repeat, 1);
138 var attempt_number: usize = 0;
139 var had_failure = false;
140 var final_failure = false;
141
142 repetitions: for (1..repeat_count + 1) |repetition| {
143 for (1..options.retries + 2) |attempt_in_repetition| {
144 attempt_number += 1;
145 const start_time = compat.nanoTimestamp();
146 var passed = try runTestFile(allocator, file.path, options);
147 const duration_ns: u64 = @intCast(compat.nanoTimestamp() - start_time);
148 const timed_out = policy.timedOut(duration_ns);
149 if (timed_out) passed = false;
150 const error_message = if (timed_out)
151 try allocator.dupe(u8, "External test file exceeded the configured timeout")
152 else if (!passed)
153 try allocator.dupe(u8, "External zig test process failed")
154 else
155 null;
156 try test_case.attempts.append(allocator, .{
157 .number = attempt_number,
158 .repetition = repetition,
159 .status = if (passed) .passed else .failed,
160 .duration_ns = duration_ns,
161 .error_message = error_message,
162 });
163 test_case.execution_time_ns += duration_ns;
164
165 if (passed) break;
166 had_failure = true;
167 test_case.error_message = error_message;
168 if (attempt_in_repetition == options.retries + 1) {
169 final_failure = true;
170 break :repetitions;
171 }
172 }
139173 }
174 test_case.status = if (final_failure) .failed else if (had_failure) .flaky else .passed;
175 if (!final_failure) test_case.error_message = null;
176 try executed_cases.append(allocator, test_case);
140177 try results.addTest(&test_case);
141178 try events.emit(.{ .test_finished = &test_case });
142179 try events.emit(.{ .suite_finished = file.relative_path });
@@ -144,14 +181,20 @@ pub fn runDiscoveredTests(
144181 // Notify UI of test file end
145182 if (options.ui_server) |server| {
146183 var buffer: [512]u8 = undefined;
147 const status = if (result) "passed" else "failed";
148 const json = try std.fmt.bufPrint(&buffer, "", .{ file.name, status, execution_time_ns });
184 const json = try std.fmt.bufPrint(&buffer, "", .{
185 file.name,
186 @tagName(test_case.status),
187 test_case.execution_time_ns,
188 test_case.attempts.items.len,
189 });
149190 try server.broadcast("test_end", json);
150191 try server.broadcast("suite_end", try std.fmt.bufPrint(&buffer, "", .{file.name}));
151192 }
152193
153 if (result) {
194 if (test_case.status == .passed) {
154195 if (options.verbose) std.debug.print(" ✓ {s} passed\n", .{file.name});
196 } else if (test_case.status == .flaky) {
197 std.debug.print(" ~ {s} flaky after {d} attempts\n", .{ file.name, test_case.attempts.items.len });
155198 } else {
156199 std.debug.print(" ✗ {s} failed\n", .{file.name});
157200 if (policy.shouldStop(&results)) {
@@ -188,7 +231,7 @@ pub fn runDiscoveredTests(
188231 try events.emit(.{ .run_finished = &results });
189232 try reporters.flush();
190233
191 return results.failed == 0;
234 return results.failed == 0 and (!options.fail_on_flaky or results.flaky == 0);
192235}
193236
194237fn externalTestNoop(_: std.mem.Allocator) !void {}
@@ -292,6 +335,9 @@ test "LoaderOptions default values" {
292335 try std.testing.expectEqual(reporter.ReporterType.spec, options.reporter_type);
293336 try std.testing.expectEqualStrings("test-results.xml", options.junit_output);
294337 try std.testing.expectEqual(@as(?u64, null), options.timeout_ms);
338 try std.testing.expectEqual(@as(usize, 0), options.retries);
339 try std.testing.expectEqual(@as(usize, 1), options.repeat);
340 try std.testing.expect(!options.fail_on_flaky);
295341 try std.testing.expectEqual(@as(?*std.Io.Writer, null), options.reporter_writer);
296342 try std.testing.expectEqual(@as(?coverage.CoverageOptions, null), options.coverage_options);
297343}
src/test_runner.zigmodified+216-23
Changes to src/test_runner.zig
@@ -19,6 +19,12 @@ pub const RunnerOptions = struct {
1919 n_jobs: ?usize = null, // Number of parallel jobs
2020 junit_output: []const u8 = "test-results.xml",
2121 timeout_ms: ?u64 = null,
22 /// Additional attempts after a failed execution.
23 retries: usize = 0,
24 /// Number of times every selected test is required to pass.
25 repeat: usize = 1,
26 /// Treat tests that pass after a retry as an unsuccessful run.
27 fail_on_flaky: bool = false,
2228 /// Optional output supplied by a CLI host. Embedded/test callers default
2329 /// to stderr so they do not interfere with Zig's stdout test protocol.
2430 reporter_writer: ?*std.Io.Writer = null,
@@ -75,7 +81,7 @@ pub const TestRunner = struct {
7581 }
7682
7783 // Run tests in parallel or sequential based on options
78 if (self.options.parallel) {
84 if (self.options.parallel and self.options.retries == 0 and self.options.repeat == 1) {
7985 const parallel_opts = parallel.ParallelOptions{
8086 .enabled = true,
8187 .n_jobs = self.options.n_jobs,
@@ -95,7 +101,8 @@ pub const TestRunner = struct {
95101 }
96102
97103 try reporters.flush();
98 return all_passed and self.results.failed == 0;
104 return all_passed and self.results.failed == 0 and
105 (!self.options.fail_on_flaky or self.results.flaky == 0);
99106 }
100107
101108 // Sequential execution (original behavior)
@@ -113,7 +120,8 @@ pub const TestRunner = struct {
113120 // Flush output
114121 try reporters.flush();
115122
116 return self.results.failed == 0;
123 return self.results.failed == 0 and
124 (!self.options.fail_on_flaky or self.results.flaky == 0);
117125 }
118126
119127 fn addSuiteResults(self: *Self, test_suite: *suite.TestSuite) !void {
@@ -189,52 +197,88 @@ pub const TestRunner = struct {
189197 fn runTest(self: *Self, test_case: *suite.TestCase, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void {
190198 try events.emit(.{ .test_started = test_case.name });
191199
200 for (test_case.attempts.items) |attempt| {
201 if (attempt.error_message) |message| self.allocator.free(message);
202 }
203 test_case.attempts.clearRetainingCapacity();
204 test_case.execution_time_ns = 0;
205 test_case.error_message = null;
206
207 const retries = test_case.retry_count orelse self.options.retries;
208 const repeat_count = @max(self.options.repeat, 1);
209 var attempt_number: usize = 0;
210 var had_failure = false;
211 var final_failure = false;
212
213 repetitions: for (1..repeat_count + 1) |repetition| {
214 for (1..retries + 2) |attempt_in_repetition| {
215 attempt_number += 1;
216 const passed = try self.runSingleAttempt(test_case, test_suite, repetition, attempt_number);
217 if (passed) break;
218
219 had_failure = true;
220 if (attempt_in_repetition == retries + 1) {
221 final_failure = true;
222 break :repetitions;
223 }
224 }
225 }
226
227 if (!final_failure) {
228 test_case.status = if (had_failure) .flaky else .passed;
229 test_case.error_message = null;
230 }
231
232 try self.results.addTest(test_case);
233 try events.emit(.{ .test_finished = test_case });
234 }
235
236 fn runSingleAttempt(
237 self: *Self,
238 test_case: *suite.TestCase,
239 test_suite: *suite.TestSuite,
240 repetition: usize,
241 attempt_number: usize,
242 ) !bool {
192243 test_case.status = .running;
244 test_case.error_message = null;
193245 const start_time = compat.nanoTimestamp();
194246
195 // Get all beforeEach hooks (including parent hooks)
196247 var before_hooks = try test_suite.getAllBeforeEachHooks(self.allocator);
197248 defer before_hooks.deinit(self.allocator);
198249
199 // Run beforeEach hooks
200250 var before_failed = false;
201251 for (before_hooks.items) |hook| {
202252 hook(self.allocator) catch |err| {
203253 test_case.status = .failed;
204 const err_msg = try std.fmt.allocPrint(self.allocator, "beforeEach hook failed: {any}", .{err});
205 test_case.error_message = err_msg;
254 test_case.error_message = try std.fmt.allocPrint(
255 self.allocator,
256 "beforeEach hook failed: {any}",
257 .{err},
258 );
206259 before_failed = true;
207260 break;
208261 };
209262 }
210263
211 // Run the actual test if beforeEach succeeded
212264 if (!before_failed) {
213265 test_case.test_fn(self.allocator) catch |err| {
214266 test_case.status = .failed;
215 const err_msg = try std.fmt.allocPrint(self.allocator, "{any}", .{err});
216 test_case.error_message = err_msg;
267 test_case.error_message = try std.fmt.allocPrint(self.allocator, "{any}", .{err});
217268 };
218
219 if (test_case.status == .running) {
220 test_case.status = .passed;
221 }
269 if (test_case.status == .running) test_case.status = .passed;
222270 }
223271
224 // Get all afterEach hooks (including parent hooks)
225272 var after_hooks = try test_suite.getAllAfterEachHooks(self.allocator);
226273 defer after_hooks.deinit(self.allocator);
227
228 // Run afterEach hooks (always run, even if test failed)
229274 for (after_hooks.items) |hook| {
230275 hook(self.allocator) catch |err| {
231276 std.debug.print("afterEach hook failed: {any}\n", .{err});
232277 };
233278 }
234279
235 const end_time = compat.nanoTimestamp();
236 test_case.execution_time_ns = @intCast(end_time - start_time);
237 if (test_case.status == .passed and self.policy().timedOut(test_case.execution_time_ns)) {
280 const duration_ns: u64 = @intCast(compat.nanoTimestamp() - start_time);
281 if (test_case.status == .passed and self.policy().timedOut(duration_ns)) {
238282 test_case.status = .failed;
239283 test_case.error_message = try std.fmt.allocPrint(
240284 self.allocator,
@@ -242,9 +286,15 @@ pub const TestRunner = struct {
242286 .{self.options.timeout_ms.?},
243287 );
244288 }
245
246 try self.results.addTest(test_case);
247 try events.emit(.{ .test_finished = test_case });
289 test_case.execution_time_ns += duration_ns;
290 try test_case.attempts.append(self.allocator, .{
291 .number = attempt_number,
292 .repetition = repetition,
293 .status = test_case.status,
294 .duration_ns = duration_ns,
295 .error_message = test_case.error_message,
296 });
297 return test_case.status == .passed;
248298 }
249299
250300 /// Skip all tests in a suite
@@ -302,6 +352,9 @@ test "RunnerOptions default values" {
302352 try std.testing.expectEqual(ReporterType.spec, options.reporter_type);
303353 try std.testing.expectEqual(true, options.use_colors);
304354 try std.testing.expectEqual(@as(?u64, null), options.timeout_ms);
355 try std.testing.expectEqual(@as(usize, 0), options.retries);
356 try std.testing.expectEqual(@as(usize, 1), options.repeat);
357 try std.testing.expect(!options.fail_on_flaky);
305358}
306359
307360test "RunnerOptions custom values" {
@@ -317,3 +370,143 @@ test "RunnerOptions custom values" {
317370 try std.testing.expectEqual(ReporterType.json, options.reporter_type);
318371 try std.testing.expectEqual(false, options.use_colors);
319372}
373
374var flaky_fixture_runs: usize = 0;
375var failing_fixture_runs: usize = 0;
376var skipped_by_bail_runs: usize = 0;
377var repeated_fixture_runs: usize = 0;
378var timeout_fixture_runs: usize = 0;
379
380fn flakyFixture(_: std.mem.Allocator) !void {
381 flaky_fixture_runs += 1;
382 if (flaky_fixture_runs == 1) return error.TransientFailure;
383}
384
385fn failingFixture(_: std.mem.Allocator) !void {
386 failing_fixture_runs += 1;
387 return error.DeterministicFailure;
388}
389
390fn skippedByBailFixture(_: std.mem.Allocator) !void {
391 skipped_by_bail_runs += 1;
392}
393
394fn repeatedFixture(_: std.mem.Allocator) !void {
395 repeated_fixture_runs += 1;
396}
397
398fn timeoutFixture(_: std.mem.Allocator) !void {
399 timeout_fixture_runs += 1;
400 compat.sleep(std.time.ns_per_ms);
401}
402
403test "retries record deterministic flaky attempt history" {
404 flaky_fixture_runs = 0;
405 const allocator = std.testing.allocator;
406 var registry = suite.TestRegistry.init(allocator);
407 defer registry.deinit();
408 const test_suite = try suite.TestSuite.init(allocator, "retry suite");
409 try test_suite.addTest(suite.TestCase.init("flaky fixture", flakyFixture).withRetries(1));
410 try registry.registerSuite(test_suite);
411
412 var output: [4096]u8 = undefined;
413 var writer: std.Io.Writer = .fixed(&output);
414 var runner = TestRunner.init(allocator, &registry, .{
415 .reporter_writer = &writer,
416 .use_colors = false,
417 });
418 defer runner.deinit();
419
420 try std.testing.expect(try runner.run());
421 const test_case = &test_suite.tests.items[0];
422 try std.testing.expectEqual(suite.TestStatus.flaky, test_case.status);
423 try std.testing.expectEqual(@as(usize, 2), test_case.attempts.items.len);
424 try std.testing.expectEqual(suite.TestStatus.failed, test_case.attempts.items[0].status);
425 try std.testing.expectEqual(suite.TestStatus.passed, test_case.attempts.items[1].status);
426 try std.testing.expectEqual(@as(usize, 1), runner.results.flaky);
427
428 var strict_runner = TestRunner.init(allocator, &registry, .{
429 .retries = 1,
430 .fail_on_flaky = true,
431 .reporter_writer = &writer,
432 .use_colors = false,
433 });
434 defer strict_runner.deinit();
435 flaky_fixture_runs = 0;
436 try std.testing.expect(!try strict_runner.run());
437}
438
439test "repeat mode runs every required passing repetition" {
440 repeated_fixture_runs = 0;
441 const allocator = std.testing.allocator;
442 var registry = suite.TestRegistry.init(allocator);
443 defer registry.deinit();
444 const test_suite = try suite.TestSuite.init(allocator, "repeat suite");
445 try test_suite.addTest(suite.TestCase.init("repeated fixture", repeatedFixture));
446 try registry.registerSuite(test_suite);
447
448 var output: [4096]u8 = undefined;
449 var writer: std.Io.Writer = .fixed(&output);
450 var runner = TestRunner.init(allocator, &registry, .{
451 .repeat = 3,
452 .reporter_writer = &writer,
453 .use_colors = false,
454 });
455 defer runner.deinit();
456
457 try std.testing.expect(try runner.run());
458 try std.testing.expectEqual(@as(usize, 3), repeated_fixture_runs);
459 try std.testing.expectEqual(@as(usize, 3), test_suite.tests.items[0].attempts.items.len);
460}
461
462test "bail waits until retries are exhausted" {
463 failing_fixture_runs = 0;
464 skipped_by_bail_runs = 0;
465 const allocator = std.testing.allocator;
466 var registry = suite.TestRegistry.init(allocator);
467 defer registry.deinit();
468 const test_suite = try suite.TestSuite.init(allocator, "bail suite");
469 try test_suite.addTest(suite.TestCase.init("always fails", failingFixture));
470 try test_suite.addTest(suite.TestCase.init("must be skipped", skippedByBailFixture));
471 try registry.registerSuite(test_suite);
472
473 var output: [4096]u8 = undefined;
474 var writer: std.Io.Writer = .fixed(&output);
475 var runner = TestRunner.init(allocator, &registry, .{
476 .bail = true,
477 .retries = 2,
478 .reporter_writer = &writer,
479 .use_colors = false,
480 });
481 defer runner.deinit();
482
483 try std.testing.expect(!try runner.run());
484 try std.testing.expectEqual(@as(usize, 3), failing_fixture_runs);
485 try std.testing.expectEqual(@as(usize, 0), skipped_by_bail_runs);
486 try std.testing.expectEqual(@as(usize, 3), test_suite.tests.items[0].attempts.items.len);
487}
488
489test "timeouts are evaluated for every retry attempt" {
490 timeout_fixture_runs = 0;
491 const allocator = std.testing.allocator;
492 var registry = suite.TestRegistry.init(allocator);
493 defer registry.deinit();
494 const test_suite = try suite.TestSuite.init(allocator, "timeout suite");
495 try test_suite.addTest(suite.TestCase.init("times out", timeoutFixture));
496 try registry.registerSuite(test_suite);
497
498 var output: [4096]u8 = undefined;
499 var writer: std.Io.Writer = .fixed(&output);
500 var runner = TestRunner.init(allocator, &registry, .{
501 .timeout_ms = 0,
502 .retries = 1,
503 .reporter_writer = &writer,
504 .use_colors = false,
505 });
506 defer runner.deinit();
507
508 try std.testing.expect(!try runner.run());
509 try std.testing.expectEqual(@as(usize, 2), timeout_fixture_runs);
510 try std.testing.expectEqual(@as(usize, 2), test_suite.tests.items[0].attempts.items.len);
511 try std.testing.expectEqual(suite.TestStatus.failed, test_suite.tests.items[0].status);
512}
tests/fixtures/zig-test.jsonmodified+4-1
Changes to tests/fixtures/zig-test.json
@@ -3,7 +3,10 @@
33 "test_dir": "tests/fixtures",
44 "pattern": "filter_fixture.zig",
55 "recursive": false,
6 "filter": "selected test passes"
6 "filter": "selected test passes",
7 "retries": 1,
8 "repeat": 2,
9 "fail_on_flaky": false
710 },
811 "sharding": {
912 "index": 1,
tests/reporter_test.zigmodified+42-0
Changes to tests/reporter_test.zig
@@ -68,6 +68,48 @@ test "reporter json output" {
6868 const output = buffer[0..reporter.writer.end];
6969 try std.testing.expect(std.mem.indexOf(u8, output, "\"name\":\"passes\"") != null);
7070 try std.testing.expect(std.mem.indexOf(u8, output, "\"passed\":1") != null);
71 var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, output, .{});
72 defer parsed.deinit();
73}
74
75test "reporter json output includes flaky attempt history" {
76 const allocator = std.testing.allocator;
77 var buffer: [4096]u8 = undefined;
78 const writer: std.Io.Writer = .fixed(&buffer);
79 var reporter = ztf.JsonReporter.init(allocator, writer);
80 defer reporter.deinit();
81 var results = ztf.TestResults.init(allocator);
82 defer results.deinit();
83 results.total = 1;
84 results.flaky = 1;
85 var test_case = ztf.TestCase.init("eventually passes", passingTest);
86 defer test_case.attempts.deinit(allocator);
87 test_case.status = .flaky;
88 try test_case.attempts.append(allocator, .{
89 .number = 1,
90 .repetition = 1,
91 .status = .failed,
92 .duration_ns = 10,
93 });
94 try test_case.attempts.append(allocator, .{
95 .number = 2,
96 .repetition = 1,
97 .status = .passed,
98 .duration_ns = 5,
99 });
100
101 try reporter.reporter.onRunStart(1);
102 try reporter.reporter.onSuiteStart("suite");
103 try reporter.reporter.onTestEnd(&test_case);
104 try reporter.reporter.onSuiteEnd("suite");
105 try reporter.reporter.onRunEnd(&results);
106
107 const output = buffer[0..reporter.writer.end];
108 try std.testing.expect(std.mem.indexOf(u8, output, "\"status\":\"flaky\"") != null);
109 try std.testing.expect(std.mem.indexOf(u8, output, "\"attempts\":[") != null);
110 try std.testing.expect(std.mem.indexOf(u8, output, "\"durationNs\":10") != null);
111 var parsed = try std.json.parseFromSlice(std.json.Value, allocator, output, .{});
112 defer parsed.deinit();
71113}
72114
73115test "reporter set selects the same built-ins for every executor" {