also looking at this
Add retries, repeated runs, and flaky-test reporting
#25
15 files
+719
-53
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.
| @@ -45,6 +45,10 @@ pub const CLIOptions = struct { | ||
| 45 | 45 | junit_output: ?[]const u8 = null, |
| 46 | 46 | // Timeout options |
| 47 | 47 | 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, | |
| 48 | 52 | }; |
| 49 | 53 | |
| 50 | 54 | pub const version = build_options.version; |
| @@ -75,6 +79,9 @@ pub const CLI = struct { | ||
| 75 | 79 | self.options.no_recursive = !config.test_options.recursive; |
| 76 | 80 | self.options.filter = config.test_options.filter; |
| 77 | 81 | 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; | |
| 78 | 85 | self.options.shard_index = config.sharding.index; |
| 79 | 86 | self.options.shard_count = config.sharding.count; |
| 80 | 87 | self.options.parallel = config.parallel.enabled; |
| @@ -107,6 +114,32 @@ pub const CLI = struct { | ||
| 107 | 114 | self.options.version = true; |
| 108 | 115 | } else if (std.mem.eql(u8, arg, "--bail") or std.mem.eql(u8, arg, "-b")) { |
| 109 | 116 | 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; | |
| 110 | 143 | } else if (std.mem.eql(u8, arg, "--verbose")) { |
| 111 | 144 | self.options.verbose = true; |
| 112 | 145 | } else if (std.mem.eql(u8, arg, "--quiet") or std.mem.eql(u8, arg, "-q")) { |
| @@ -351,6 +384,9 @@ pub const CLI = struct { | ||
| 351 | 384 | \\ -h, --help Show this help message |
| 352 | 385 | \\ -v, --version Show version information |
| 353 | 386 | \\ -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 | |
| 354 | 390 | \\ --filter <pattern> Run only tests matching pattern |
| 355 | 391 | \\ --grep <pattern> Same as --filter (alias) |
| 356 | 392 | \\ --reporter <name> Set reporter type (spec, dot, json, tap, junit) |
| @@ -414,6 +450,8 @@ pub const CLI = struct { | ||
| 414 | 450 | \\ zig-test --update-snapshots Update all snapshots |
| 415 | 451 | \\ zig-test --watch Run in watch mode |
| 416 | 452 | \\ 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 | |
| 417 | 455 | \\ zig-test --profile-memory --fail-on-leak |
| 418 | 456 | \\ zig-test --parallel --jobs 4 Run with 4 parallel workers |
| 419 | 457 | \\ zig-test --config zig-test.json Load config from file |
| @@ -441,6 +479,9 @@ pub const CLI = struct { | ||
| 441 | 479 | .n_jobs = self.options.jobs, |
| 442 | 480 | .junit_output = self.options.junit_output orelse "test-results.xml", |
| 443 | 481 | .timeout_ms = self.options.timeout, |
| 482 | .retries = self.options.retries, | |
| 483 | .repeat = self.options.repeat, | |
| 484 | .fail_on_flaky = self.options.fail_on_flaky, | |
| 444 | 485 | }; |
| 445 | 486 | } |
| 446 | 487 | }; |
| @@ -662,6 +703,35 @@ test "CLI parse timeout" { | ||
| 662 | 703 | try std.testing.expectEqual(@as(?u64, 5000), cli.options.timeout); |
| 663 | 704 | } |
| 664 | 705 | |
| 706 | test "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 | ||
| 727 | test "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 | ||
| 665 | 735 | test "CLI rejects invalid coverage tool" { |
| 666 | 736 | var cli = CLI.init(std.testing.allocator); |
| 667 | 737 | const args = [_][]const u8{ "zig-test", "--coverage-tool", "unknown" }; |
| @@ -29,6 +29,9 @@ pub const TestOptions = struct { | ||
| 29 | 29 | recursive: bool = true, |
| 30 | 30 | filter: ?[]const u8 = null, |
| 31 | 31 | timeout: ?u64 = null, |
| 32 | retries: usize = 0, | |
| 33 | repeat: usize = 1, | |
| 34 | fail_on_flaky: bool = false, | |
| 32 | 35 | }; |
| 33 | 36 | |
| 34 | 37 | pub const ParallelOptions = struct { |
| @@ -86,6 +89,7 @@ pub const ConfigError = error{ | ||
| 86 | 89 | InvalidJobs, |
| 87 | 90 | InvalidPort, |
| 88 | 91 | InvalidTimeout, |
| 92 | InvalidRepeat, | |
| 89 | 93 | InvalidDebounce, |
| 90 | 94 | InvalidShard, |
| 91 | 95 | }; |
| @@ -183,6 +187,7 @@ fn validate(config: TestConfig) !void { | ||
| 183 | 187 | if (config.test_options.timeout) |timeout| { |
| 184 | 188 | if (timeout == 0) return ConfigError.InvalidTimeout; |
| 185 | 189 | } |
| 190 | if (config.test_options.repeat == 0) return ConfigError.InvalidRepeat; | |
| 186 | 191 | if (config.watch.debounce_ms == 0) return ConfigError.InvalidDebounce; |
| 187 | 192 | const has_shard_index = config.sharding.index != null; |
| 188 | 193 | const has_shard_count = config.sharding.count != null; |
| @@ -199,6 +204,9 @@ test "TestConfig defaults match CLI discovery defaults" { | ||
| 199 | 204 | try std.testing.expectEqualStrings(".", config.test_options.test_dir); |
| 200 | 205 | try std.testing.expect(config.test_options.recursive); |
| 201 | 206 | 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); | |
| 202 | 210 | try std.testing.expect(config.sharding.index == null); |
| 203 | 211 | try std.testing.expect(config.sharding.count == null); |
| 204 | 212 | try std.testing.expect(!config.parallel.enabled); |
| @@ -219,6 +227,9 @@ test "configuration validation rejects invalid runtime values" { | ||
| 219 | 227 | invalid.parallel.jobs = 0; |
| 220 | 228 | try std.testing.expectError(ConfigError.InvalidJobs, validate(invalid)); |
| 221 | 229 | 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; | |
| 222 | 233 | invalid.sharding.index = 2; |
| 223 | 234 | try std.testing.expectError(ConfigError.InvalidShard, validate(invalid)); |
| 224 | 235 | invalid.sharding.count = 1; |
| @@ -60,6 +60,7 @@ pub const describeTimeout = suite.describeTimeout; | ||
| 60 | 60 | pub const TestSuite = suite.TestSuite; |
| 61 | 61 | pub const TestCase = suite.TestCase; |
| 62 | 62 | pub const TestStatus = suite.TestStatus; |
| 63 | pub const TestAttempt = suite.TestAttempt; | |
| 63 | 64 | pub const TestRegistry = suite.TestRegistry; |
| 64 | 65 | pub const TestFn = suite.TestFn; |
| 65 | 66 | pub const HookFn = suite.HookFn; |
| @@ -221,6 +221,9 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 221 | 221 | .reporter_type = cli_parser.options.reporter, |
| 222 | 222 | .junit_output = cli_parser.options.junit_output orelse "test-results.xml", |
| 223 | 223 | .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, | |
| 224 | 227 | .reporter_writer = &stdout_writer.interface, |
| 225 | 228 | .coverage_options = cov_opts, |
| 226 | 229 | .ui_server = if (ui_server) |*server| server else null, |
| @@ -264,6 +267,9 @@ pub fn main(init: std.process.Init.Minimal) !void { | ||
| 264 | 267 | .reporter_type = cli_parser.options.reporter, |
| 265 | 268 | .junit_output = cli_parser.options.junit_output orelse "test-results.xml", |
| 266 | 269 | .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, | |
| 267 | 273 | .reporter_writer = &stdout_writer.interface, |
| 268 | 274 | .coverage_options = cov_opts, |
| 269 | 275 | .ui_server = if (ui_server) |*server| server else null, |
| @@ -71,21 +71,25 @@ pub const Reporter = struct { | ||
| 71 | 71 | pub const TestResults = struct { |
| 72 | 72 | total: usize = 0, |
| 73 | 73 | passed: usize = 0, |
| 74 | flaky: usize = 0, | |
| 74 | 75 | failed: usize = 0, |
| 75 | 76 | skipped: usize = 0, |
| 76 | 77 | total_time_ns: u64 = 0, |
| 77 | 78 | failed_tests: std.ArrayList(suite.TestCase), |
| 79 | flaky_tests: std.ArrayList(suite.TestCase), | |
| 78 | 80 | allocator: std.mem.Allocator, |
| 79 | 81 | |
| 80 | 82 | pub fn init(allocator: std.mem.Allocator) TestResults { |
| 81 | 83 | return TestResults{ |
| 82 | 84 | .failed_tests = .empty, |
| 85 | .flaky_tests = .empty, | |
| 83 | 86 | .allocator = allocator, |
| 84 | 87 | }; |
| 85 | 88 | } |
| 86 | 89 | |
| 87 | 90 | pub fn deinit(self: *TestResults) void { |
| 88 | 91 | self.failed_tests.deinit(self.allocator); |
| 92 | self.flaky_tests.deinit(self.allocator); | |
| 89 | 93 | } |
| 90 | 94 | |
| 91 | 95 | pub fn addTest(self: *TestResults, test_case: *const suite.TestCase) !void { |
| @@ -94,6 +98,10 @@ pub const TestResults = struct { | ||
| 94 | 98 | |
| 95 | 99 | switch (test_case.status) { |
| 96 | 100 | .passed => self.passed += 1, |
| 101 | .flaky => { | |
| 102 | self.flaky += 1; | |
| 103 | try self.flaky_tests.append(self.allocator, test_case.*); | |
| 104 | }, | |
| 97 | 105 | .failed => { |
| 98 | 106 | self.failed += 1; |
| 99 | 107 | try self.failed_tests.append(self.allocator, test_case.*); |
| @@ -188,6 +196,9 @@ pub const SpecReporter = struct { | ||
| 188 | 196 | try s.output().print("{s}Test Summary:{s}\n", .{ Colors.bold, Colors.reset }); |
| 189 | 197 | try s.output().print(" Total: {d}\n", .{results.total}); |
| 190 | 198 | 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 | } | |
| 191 | 202 | if (results.failed > 0) { |
| 192 | 203 | try s.output().print(" {s}Failed: {d}{s}\n", .{ Colors.red, results.failed, Colors.reset }); |
| 193 | 204 | } |
| @@ -199,6 +210,9 @@ pub const SpecReporter = struct { | ||
| 199 | 210 | try s.output().print("Test Summary:\n", .{}); |
| 200 | 211 | try s.output().print(" Total: {d}\n", .{results.total}); |
| 201 | 212 | 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 | } | |
| 202 | 216 | if (results.failed > 0) { |
| 203 | 217 | try s.output().print(" Failed: {d}\n", .{results.failed}); |
| 204 | 218 | } |
| @@ -265,6 +279,25 @@ pub const SpecReporter = struct { | ||
| 265 | 279 | try s.output().print("✓ {s} ({d:.2}ms)\n", .{ test_case.name, time_ms }); |
| 266 | 280 | } |
| 267 | 281 | }, |
| 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 | }, | |
| 268 | 301 | .failed => { |
| 269 | 302 | if (reporter.use_colors) { |
| 270 | 303 | try s.output().print("{s}✗{s} {s} {s}({d:.2}ms){s}\n", .{ |
| @@ -353,11 +386,13 @@ pub const DotReporter = struct { | ||
| 353 | 386 | |
| 354 | 387 | if (reporter.use_colors) { |
| 355 | 388 | 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 }); | |
| 356 | 390 | try s.output().print("{s}Failed: {d}{s}, ", .{ Colors.red, results.failed, Colors.reset }); |
| 357 | 391 | try s.output().print("Total: {d} ({d:.2}ms)\n", .{ results.total, total_time_ms }); |
| 358 | 392 | } 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", .{ | |
| 360 | 394 | results.passed, |
| 395 | results.flaky, | |
| 361 | 396 | results.failed, |
| 362 | 397 | results.total, |
| 363 | 398 | total_time_ms, |
| @@ -391,6 +426,13 @@ pub const DotReporter = struct { | ||
| 391 | 426 | try s.output().print(".", .{}); |
| 392 | 427 | } |
| 393 | 428 | }, |
| 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 | }, | |
| 394 | 436 | .failed => { |
| 395 | 437 | if (reporter.use_colors) { |
| 396 | 438 | try s.output().print("{s}F{s}", .{ Colors.red, Colors.reset }); |
| @@ -422,6 +464,7 @@ pub const JsonReporter = struct { | ||
| 422 | 464 | writer: std.Io.Writer, |
| 423 | 465 | writer_ref: ?*std.Io.Writer = null, |
| 424 | 466 | suites: std.ArrayList([]const u8), |
| 467 | emitted_tests: bool = false, | |
| 425 | 468 | |
| 426 | 469 | const Self = @This(); |
| 427 | 470 | |
| @@ -472,9 +515,10 @@ pub const JsonReporter = struct { | ||
| 472 | 515 | const s = self(reporter); |
| 473 | 516 | const total_time_ms = @as(f64, @floatFromInt(results.total_time_ns)) / 1_000_000.0; |
| 474 | 517 | |
| 475 | try s.output().print("],\"summary\":}}\n", .{ | |
| 518 | try s.output().print("\n],\"summary\":}}\n", .{ | |
| 476 | 519 | results.total, |
| 477 | 520 | results.passed, |
| 521 | results.flaky, | |
| 478 | 522 | results.failed, |
| 479 | 523 | results.skipped, |
| 480 | 524 | total_time_ms, |
| @@ -505,11 +549,15 @@ pub const JsonReporter = struct { | ||
| 505 | 549 | |
| 506 | 550 | const status_str = switch (test_case.status) { |
| 507 | 551 | .passed => "passed", |
| 552 | .flaky => "flaky", | |
| 508 | 553 | .failed => "failed", |
| 509 | 554 | .skipped => "skipped", |
| 510 | 555 | else => "unknown", |
| 511 | 556 | }; |
| 512 | 557 | |
| 558 | if (s.emitted_tests) try s.output().print(",\n", .{}); | |
| 559 | s.emitted_tests = true; | |
| 560 | ||
| 513 | 561 | // Note: In a real implementation, you'd want to properly escape JSON strings |
| 514 | 562 | try s.output().print(" ", .{}); |
| 525 | 592 | } |
| 526 | 593 | }; |
| 527 | 594 | |
| @@ -593,6 +660,7 @@ pub const TAPReporter = struct { | ||
| 593 | 660 | |
| 594 | 661 | const status = switch (test_case.status) { |
| 595 | 662 | .passed => "ok", |
| 663 | .flaky => "ok", | |
| 596 | 664 | .failed => "not ok", |
| 597 | 665 | .skipped => "ok", |
| 598 | 666 | else => "not ok", |
| @@ -600,7 +668,9 @@ pub const TAPReporter = struct { | ||
| 600 | 668 | |
| 601 | 669 | try self.output().print("{s} {d} - {s}", .{ status, self.test_count, test_case.name }); |
| 602 | 670 | |
| 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) { | |
| 604 | 674 | try self.output().print(" # SKIP\n", .{}); |
| 605 | 675 | } else if (test_case.status == .failed and test_case.error_message != null) { |
| 606 | 676 | try self.output().print("\n ---\n message: {s}\n ...\n", .{test_case.error_message.?}); |
| @@ -631,6 +701,10 @@ pub const JUnitReporter = struct { | ||
| 631 | 701 | if (test_case.error_message) |msg| { |
| 632 | 702 | allocator.free(msg); |
| 633 | 703 | } |
| 704 | for (test_case.attempts) |attempt| { | |
| 705 | if (attempt.error_message) |msg| allocator.free(msg); | |
| 706 | } | |
| 707 | allocator.free(test_case.attempts); | |
| 634 | 708 | } |
| 635 | 709 | self.tests.deinit(allocator); |
| 636 | 710 | allocator.free(self.name); |
| @@ -642,6 +716,15 @@ pub const JUnitReporter = struct { | ||
| 642 | 716 | time: f64, |
| 643 | 717 | status: suite.TestStatus, |
| 644 | 718 | 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, | |
| 645 | 728 | }; |
| 646 | 729 | |
| 647 | 730 | pub fn init(allocator: std.mem.Allocator, output_file: []const u8) Self { |
| @@ -716,11 +799,27 @@ pub const JUnitReporter = struct { | ||
| 716 | 799 | else |
| 717 | 800 | null; |
| 718 | 801 | |
| 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 | ||
| 719 | 817 | const test_result = TestCaseResult{ |
| 720 | 818 | .name = try self.allocator.dupe(u8, test_case.name), |
| 721 | 819 | .time = time_seconds, |
| 722 | 820 | .status = test_case.status, |
| 723 | 821 | .error_message = error_msg, |
| 822 | .attempts = attempts, | |
| 724 | 823 | }; |
| 725 | 824 | |
| 726 | 825 | try test_suite.tests.append(self.allocator, test_result); |
| @@ -732,10 +831,11 @@ pub const JUnitReporter = struct { | ||
| 732 | 831 | defer buffer.deinit(self.allocator); |
| 733 | 832 | |
| 734 | 833 | 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", .{ | |
| 736 | 835 | results.total, |
| 737 | 836 | results.failed, |
| 738 | 837 | results.skipped, |
| 838 | results.flaky, | |
| 739 | 839 | }); |
| 740 | 840 | |
| 741 | 841 | for (self.suites.items) |suite_result| { |
| @@ -768,11 +868,16 @@ pub const JUnitReporter = struct { | ||
| 768 | 868 | try buffer.print(self.allocator, " <failure message=\"{s}\"/>\n", .{ |
| 769 | 869 | test_result.error_message orelse "Test failed", |
| 770 | 870 | }); |
| 871 | try writeAttemptHistory(&buffer, self.allocator, test_result.attempts); | |
| 771 | 872 | try buffer.appendSlice(self.allocator, " </testcase>\n"); |
| 772 | 873 | } else if (test_result.status == .skipped) { |
| 773 | 874 | try buffer.appendSlice(self.allocator, ">\n"); |
| 774 | 875 | try buffer.appendSlice(self.allocator, " <skipped/>\n"); |
| 775 | 876 | 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"); | |
| 776 | 881 | } else { |
| 777 | 882 | try buffer.appendSlice(self.allocator, "/>\n"); |
| 778 | 883 | } |
| @@ -785,6 +890,23 @@ pub const JUnitReporter = struct { | ||
| 785 | 890 | |
| 786 | 891 | try compat.writeFile(self.allocator, self.output_file, buffer.items); |
| 787 | 892 | } |
| 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, " "); | |
| 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 | } | |
| 788 | 910 | }; |
| 789 | 911 | |
| 790 | 912 | /// Owns every built-in reporter and selects one through a shared interface. |
| @@ -24,10 +24,21 @@ pub const TestStatus = enum { | ||
| 24 | 24 | pending, |
| 25 | 25 | running, |
| 26 | 26 | passed, |
| 27 | flaky, | |
| 27 | 28 | failed, |
| 28 | 29 | skipped, |
| 29 | 30 | }; |
| 30 | 31 | |
| 32 | /// The outcome of one execution of a test. A logical test may have several | |
| 33 | /// attempts when retries or repeated runs are enabled. | |
| 34 | pub const TestAttempt = struct { | |
| 35 | number: usize, | |
| 36 | repetition: usize, | |
| 37 | status: TestStatus, | |
| 38 | duration_ns: u64, | |
| 39 | error_message: ?[]const u8 = null, | |
| 40 | }; | |
| 41 | ||
| 31 | 42 | /// Individual test case |
| 32 | 43 | pub const TestCase = struct { |
| 33 | 44 | name: []const u8, |
| @@ -37,6 +48,9 @@ pub const TestCase = struct { | ||
| 37 | 48 | status: TestStatus = .pending, |
| 38 | 49 | error_message: ?[]const u8 = null, |
| 39 | 50 | 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, | |
| 40 | 54 | skip: bool = false, |
| 41 | 55 | only: bool = false, |
| 42 | 56 | file: []const u8 = "", |
| @@ -65,6 +79,20 @@ pub const TestCase = struct { | ||
| 65 | 79 | .timeout_ms = timeout_ms, |
| 66 | 80 | }; |
| 67 | 81 | } |
| 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 | } | |
| 68 | 96 | }; |
| 69 | 97 | |
| 70 | 98 | /// Test suite (describe block) |
| @@ -103,6 +131,7 @@ pub const TestSuite = struct { | ||
| 103 | 131 | for (self.suites.items) |suite| { |
| 104 | 132 | suite.deinit(); |
| 105 | 133 | } |
| 134 | for (self.tests.items) |*test_case| test_case.deinit(self.allocator); | |
| 106 | 135 | self.tests.deinit(self.allocator); |
| 107 | 136 | self.suites.deinit(self.allocator); |
| 108 | 137 | self.before_each_hooks.deinit(self.allocator); |
| @@ -456,12 +485,14 @@ test "TestStatus enum values" { | ||
| 456 | 485 | const pending_status = TestStatus.pending; |
| 457 | 486 | const running_status = TestStatus.running; |
| 458 | 487 | const passed_status = TestStatus.passed; |
| 488 | const flaky_status = TestStatus.flaky; | |
| 459 | 489 | const failed_status = TestStatus.failed; |
| 460 | 490 | const skipped_status = TestStatus.skipped; |
| 461 | 491 | |
| 462 | 492 | try std.testing.expect(pending_status == .pending); |
| 463 | 493 | try std.testing.expect(running_status == .running); |
| 464 | 494 | try std.testing.expect(passed_status == .passed); |
| 495 | try std.testing.expect(flaky_status == .flaky); | |
| 465 | 496 | try std.testing.expect(failed_status == .failed); |
| 466 | 497 | try std.testing.expect(skipped_status == .skipped); |
| 467 | 498 | } |
| @@ -26,6 +26,12 @@ pub const LoaderOptions = struct { | ||
| 26 | 26 | junit_output: []const u8 = "test-results.xml", |
| 27 | 27 | /// Global per-file timeout checked by the shared execution policy |
| 28 | 28 | 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, | |
| 29 | 35 | /// Optional output supplied by a CLI host. Embedded/test callers default |
| 30 | 36 | /// to stderr so they do not interfere with Zig's stdout test protocol. |
| 31 | 37 | reporter_writer: ?*std.Io.Writer = null, |
| @@ -85,6 +91,11 @@ pub fn runDiscoveredTests( | ||
| 85 | 91 | }; |
| 86 | 92 | var results = reporter.TestResults.init(allocator); |
| 87 | 93 | 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 | } | |
| 88 | 99 | |
| 89 | 100 | try events.emit(.{ .run_started = selected_files }); |
| 90 | 101 | |
| @@ -120,23 +131,49 @@ pub fn runDiscoveredTests( | ||
| 120 | 131 | try server.broadcast("test_start", json); |
| 121 | 132 | } |
| 122 | 133 | |
| 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 | ||
| 130 | 134 | files_run += 1; |
| 131 | 135 | var test_case = suite.TestCase.init(file.name, externalTestNoop); |
| 132 | 136 | 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 | } | |
| 139 | 173 | } |
| 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); | |
| 140 | 177 | try results.addTest(&test_case); |
| 141 | 178 | try events.emit(.{ .test_finished = &test_case }); |
| 142 | 179 | try events.emit(.{ .suite_finished = file.relative_path }); |
| @@ -144,14 +181,20 @@ pub fn runDiscoveredTests( | ||
| 144 | 181 | // Notify UI of test file end |
| 145 | 182 | if (options.ui_server) |server| { |
| 146 | 183 | 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 | }); | |
| 149 | 190 | try server.broadcast("test_end", json); |
| 150 | 191 | try server.broadcast("suite_end", try std.fmt.bufPrint(&buffer, "", .{file.name})); |
| 151 | 192 | } |
| 152 | 193 | |
| 153 | if (result) { | |
| 194 | if (test_case.status == .passed) { | |
| 154 | 195 | 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 }); | |
| 155 | 198 | } else { |
| 156 | 199 | std.debug.print(" ✗ {s} failed\n", .{file.name}); |
| 157 | 200 | if (policy.shouldStop(&results)) { |
| @@ -188,7 +231,7 @@ pub fn runDiscoveredTests( | ||
| 188 | 231 | try events.emit(.{ .run_finished = &results }); |
| 189 | 232 | try reporters.flush(); |
| 190 | 233 | |
| 191 | return results.failed == 0; | |
| 234 | return results.failed == 0 and (!options.fail_on_flaky or results.flaky == 0); | |
| 192 | 235 | } |
| 193 | 236 | |
| 194 | 237 | fn externalTestNoop(_: std.mem.Allocator) !void {} |
| @@ -292,6 +335,9 @@ test "LoaderOptions default values" { | ||
| 292 | 335 | try std.testing.expectEqual(reporter.ReporterType.spec, options.reporter_type); |
| 293 | 336 | try std.testing.expectEqualStrings("test-results.xml", options.junit_output); |
| 294 | 337 | 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); | |
| 295 | 341 | try std.testing.expectEqual(@as(?*std.Io.Writer, null), options.reporter_writer); |
| 296 | 342 | try std.testing.expectEqual(@as(?coverage.CoverageOptions, null), options.coverage_options); |
| 297 | 343 | } |
| @@ -19,6 +19,12 @@ pub const RunnerOptions = struct { | ||
| 19 | 19 | n_jobs: ?usize = null, // Number of parallel jobs |
| 20 | 20 | junit_output: []const u8 = "test-results.xml", |
| 21 | 21 | 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, | |
| 22 | 28 | /// Optional output supplied by a CLI host. Embedded/test callers default |
| 23 | 29 | /// to stderr so they do not interfere with Zig's stdout test protocol. |
| 24 | 30 | reporter_writer: ?*std.Io.Writer = null, |
| @@ -75,7 +81,7 @@ pub const TestRunner = struct { | ||
| 75 | 81 | } |
| 76 | 82 | |
| 77 | 83 | // 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) { | |
| 79 | 85 | const parallel_opts = parallel.ParallelOptions{ |
| 80 | 86 | .enabled = true, |
| 81 | 87 | .n_jobs = self.options.n_jobs, |
| @@ -95,7 +101,8 @@ pub const TestRunner = struct { | ||
| 95 | 101 | } |
| 96 | 102 | |
| 97 | 103 | 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); | |
| 99 | 106 | } |
| 100 | 107 | |
| 101 | 108 | // Sequential execution (original behavior) |
| @@ -113,7 +120,8 @@ pub const TestRunner = struct { | ||
| 113 | 120 | // Flush output |
| 114 | 121 | try reporters.flush(); |
| 115 | 122 | |
| 116 | return self.results.failed == 0; | |
| 123 | return self.results.failed == 0 and | |
| 124 | (!self.options.fail_on_flaky or self.results.flaky == 0); | |
| 117 | 125 | } |
| 118 | 126 | |
| 119 | 127 | fn addSuiteResults(self: *Self, test_suite: *suite.TestSuite) !void { |
| @@ -189,52 +197,88 @@ pub const TestRunner = struct { | ||
| 189 | 197 | fn runTest(self: *Self, test_case: *suite.TestCase, test_suite: *suite.TestSuite, events: pipeline.EventStream) !void { |
| 190 | 198 | try events.emit(.{ .test_started = test_case.name }); |
| 191 | 199 | |
| 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 { | |
| 192 | 243 | test_case.status = .running; |
| 244 | test_case.error_message = null; | |
| 193 | 245 | const start_time = compat.nanoTimestamp(); |
| 194 | 246 | |
| 195 | // Get all beforeEach hooks (including parent hooks) | |
| 196 | 247 | var before_hooks = try test_suite.getAllBeforeEachHooks(self.allocator); |
| 197 | 248 | defer before_hooks.deinit(self.allocator); |
| 198 | 249 | |
| 199 | // Run beforeEach hooks | |
| 200 | 250 | var before_failed = false; |
| 201 | 251 | for (before_hooks.items) |hook| { |
| 202 | 252 | hook(self.allocator) catch |err| { |
| 203 | 253 | 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 | ); | |
| 206 | 259 | before_failed = true; |
| 207 | 260 | break; |
| 208 | 261 | }; |
| 209 | 262 | } |
| 210 | 263 | |
| 211 | // Run the actual test if beforeEach succeeded | |
| 212 | 264 | if (!before_failed) { |
| 213 | 265 | test_case.test_fn(self.allocator) catch |err| { |
| 214 | 266 | 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}); | |
| 217 | 268 | }; |
| 218 | ||
| 219 | if (test_case.status == .running) { | |
| 220 | test_case.status = .passed; | |
| 221 | } | |
| 269 | if (test_case.status == .running) test_case.status = .passed; | |
| 222 | 270 | } |
| 223 | 271 | |
| 224 | // Get all afterEach hooks (including parent hooks) | |
| 225 | 272 | var after_hooks = try test_suite.getAllAfterEachHooks(self.allocator); |
| 226 | 273 | defer after_hooks.deinit(self.allocator); |
| 227 | ||
| 228 | // Run afterEach hooks (always run, even if test failed) | |
| 229 | 274 | for (after_hooks.items) |hook| { |
| 230 | 275 | hook(self.allocator) catch |err| { |
| 231 | 276 | std.debug.print("afterEach hook failed: {any}\n", .{err}); |
| 232 | 277 | }; |
| 233 | 278 | } |
| 234 | 279 | |
| 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)) { | |
| 238 | 282 | test_case.status = .failed; |
| 239 | 283 | test_case.error_message = try std.fmt.allocPrint( |
| 240 | 284 | self.allocator, |
| @@ -242,9 +286,15 @@ pub const TestRunner = struct { | ||
| 242 | 286 | .{self.options.timeout_ms.?}, |
| 243 | 287 | ); |
| 244 | 288 | } |
| 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; | |
| 248 | 298 | } |
| 249 | 299 | |
| 250 | 300 | /// Skip all tests in a suite |
| @@ -302,6 +352,9 @@ test "RunnerOptions default values" { | ||
| 302 | 352 | try std.testing.expectEqual(ReporterType.spec, options.reporter_type); |
| 303 | 353 | try std.testing.expectEqual(true, options.use_colors); |
| 304 | 354 | 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); | |
| 305 | 358 | } |
| 306 | 359 | |
| 307 | 360 | test "RunnerOptions custom values" { |
| @@ -317,3 +370,143 @@ test "RunnerOptions custom values" { | ||
| 317 | 370 | try std.testing.expectEqual(ReporterType.json, options.reporter_type); |
| 318 | 371 | try std.testing.expectEqual(false, options.use_colors); |
| 319 | 372 | } |
| 373 | ||
| 374 | var flaky_fixture_runs: usize = 0; | |
| 375 | var failing_fixture_runs: usize = 0; | |
| 376 | var skipped_by_bail_runs: usize = 0; | |
| 377 | var repeated_fixture_runs: usize = 0; | |
| 378 | var timeout_fixture_runs: usize = 0; | |
| 379 | ||
| 380 | fn flakyFixture(_: std.mem.Allocator) !void { | |
| 381 | flaky_fixture_runs += 1; | |
| 382 | if (flaky_fixture_runs == 1) return error.TransientFailure; | |
| 383 | } | |
| 384 | ||
| 385 | fn failingFixture(_: std.mem.Allocator) !void { | |
| 386 | failing_fixture_runs += 1; | |
| 387 | return error.DeterministicFailure; | |
| 388 | } | |
| 389 | ||
| 390 | fn skippedByBailFixture(_: std.mem.Allocator) !void { | |
| 391 | skipped_by_bail_runs += 1; | |
| 392 | } | |
| 393 | ||
| 394 | fn repeatedFixture(_: std.mem.Allocator) !void { | |
| 395 | repeated_fixture_runs += 1; | |
| 396 | } | |
| 397 | ||
| 398 | fn timeoutFixture(_: std.mem.Allocator) !void { | |
| 399 | timeout_fixture_runs += 1; | |
| 400 | compat.sleep(std.time.ns_per_ms); | |
| 401 | } | |
| 402 | ||
| 403 | test "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, ®istry, .{ | |
| 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, ®istry, .{ | |
| 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 | ||
| 439 | test "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, ®istry, .{ | |
| 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 | ||
| 462 | test "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, ®istry, .{ | |
| 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 | ||
| 489 | test "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, ®istry, .{ | |
| 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 | } | |
| @@ -68,6 +68,48 @@ test "reporter json output" { | ||
| 68 | 68 | const output = buffer[0..reporter.writer.end]; |
| 69 | 69 | try std.testing.expect(std.mem.indexOf(u8, output, "\"name\":\"passes\"") != null); |
| 70 | 70 | 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 | ||
| 75 | test "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(); | |
| 71 | 113 | } |
| 72 | 114 | |
| 73 | 115 | test "reporter set selects the same built-ins for every executor" { |