Specimen Report · Zig

zigcheck

phiat/zigcheck

Property-based testing for Zig — generate random inputs, check properties, automatically shrink failing cases to minimal counterexamples. 40+ generators, automatic shrinking, ~93% QuickCheck parity.

Stars
★ 1
Forks
⑂ 0
Language
Zig
Size
145 kB
Last Push
2mo ago
Forged
2mo ago
fuzzingproperty-based-testingquickchecktestingzig
# zigcheck [![Zig](https://img.shields.io/badge/Zig-0.15.2-f7a41d?logo=zig&logoColor=white)](https://ziglang.org) [![Tests](https://img.shields.io/badge/tests-184_passing-brightgreen)](#running-tests) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Version](https://img.shields.io/badge/version-0.5.5-orange)](build.zig.zon) [![Generators](https://img.shields.io/badge/generators-40%2B-blueviolet)](#generators) [![Shrinking](https://img.shields.io/badge/shrinking-automatic-success)](#shrinking) [![QuickCheck](https://img.shields.io/badge/QuickCheck_parity-~93%25-informational)](#api) Property-based testing for Zig, inspired by Haskell's [QuickCheck](https://hackage.haskell.org/package/QuickCheck). Generate random structured inputs, check properties, and automatically shrink failing cases to minimal counterexamples. ## Quick start The #1 property-based testing pattern: **if you encode it, you should be able to decode it back**. ```zig const std = @import("std"); const zigcheck = @import("zigcheck"); test "integers survive format/parse roundtrip" { try zigcheck.forAll(i32, zigcheck.generators.int(i32), struct { fn prop(n: i32) !void { var buf: [20]u8 = undefined; const str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return; const parsed = std.fmt.parseInt(i32, str, 10) catch return error.PropertyFalsified; if (parsed != n) return error.PropertyFalsified; } }.prop); } ``` > **Why `struct { fn prop(...) ... }.prop`?** Zig has no anonymous function literals. > This pattern — declaring a function inside an anonymous struct and taking its > pointer — is the idiomatic workaround. It appears in every zigcheck property. > You are not doing it wrong; this is the correct form. This pattern — encode, decode, compare — transfers directly to JSON, protobuf, custom wire formats, anything with a serialization layer. When a property fails, zigcheck **automatically shrinks** the counterexample to the smallest failing input: ``` --- zigcheck: FAILED after 12 tests ------------------------ Counterexample: 10 Shrunk (8 steps) from: 1847382901 Reproduction seed: 0x2a Rerun with: .seed = 0x2a ------------------------------------------------------------- ``` Specialized generators catch bugs in domain-specific code — here, verifying that every Unicode code point roundtrips through UTF-8: ```zig test "utf-8 encoding roundtrips all code points" { try zigcheck.forAll(u21, zigcheck.generators.unicodeChar(), struct { fn prop(codepoint: u21) !void { var buf: [4]u8 = undefined; const len = std.unicode.utf8Encode(codepoint, &buf) catch return error.PropertyFalsified; const decoded = std.unicode.utf8Decode(buf[0..len]) catch return error.PropertyFalsified; if (decoded != codepoint) return error.PropertyFalsified; } }.prop); } ``` ## Multi-argument properties Test properties over multiple independently-generated values with `forAllZip`. Each argument is generated and shrunk independently: ```zig test "addition is commutative" { try zigcheck.forAllZip(.{ zigcheck.generators.int(i32), zigcheck.generators.int(i32), }, struct { fn prop(a: i32, b: i32) !void { if (a +% b != b +% a) return error.PropertyFalsified; } }.prop); } ``` Works with any number of generators. Use `forAllZipWith` for explicit config: ```zig try zigcheck.forAllZipWith(.{ .num_tests = 500, .seed = 0x1 }, .{ zigcheck.generators.int(i32), zigcheck.generators.asciiString(50), }, struct { fn prop(n: i32, s: []const u8) !void { _ = n; _ = s; } }.prop); ``` When shrinking, each argument shrinks independently toward its minimal failing value. A failing case `(10000, "hello world")` shrinks to something like `(1, "a")`. ## Struct generators You have a struct and you need a generator. Use this decision tree: **1. All fields are primitive or standard types?** Use `auto(T)`. It derives generators for every field via comptime reflection and shrinks each field independently. ```zig const Point = struct { x: i32, y: i32 }; try zigcheck.forAll(Point, zigcheck.auto(Point), prop); ``` **2. Some fields need domain-specific generators?** Use `build(T, .{ ... })`. Provide one generator per field in declaration order. You get per-field shrinking for free. **This is the recommended default for struct generators with any domain constraints.** ```zig const DefnSpec = struct { ret_type: []const u8, fn_name: []const u8, arity: u8, }; fn defnSpecGen() zigcheck.Gen(DefnSpec) { return zigcheck.build(DefnSpec, .{ typeGen(), // ret_type identGen(), // fn_name zigcheck.generators.intRange(u8, 0, 4), // arity }); } ``` **3. Fields have cross-field dependencies?** Write a manual generator. Use `Gen(T).fromGenFn` for a quick generator with no shrinking, or `assume()` in the property to discard invalid combinations: ```zig fn pairedRangeGen() zigcheck.Gen(Range) { return zigcheck.Gen(Range).fromGenFn(struct { fn f(rng: std.Random, alloc: std.mem.Allocator, size: usize) Range { const lo = zigcheck.generators.int(i32).generate(rng, alloc, size); const hi = lo +| zigcheck.generators.positive(i32).generate(rng, alloc, size); return .{ .lo = lo, .hi = hi }; } }.f); } ``` Note: `fromGenFn` produces no shrink candidates. If shrinking matters, decompose into independent sub-generators and use `build`. **4. The type should carry its own generator?** Declare `pub const zigcheck_gen` on the type. `auto(T)` checks for this before deriving. Use sparingly — it couples test infrastructure into production types. Prefer `build` or a standalone generator function for test-only types. ```zig const Money = struct { cents: u32, pub const zigcheck_gen = zigcheck.Gen(Money){ .genFn = &struct { fn f(rng: std.Random, _: std.mem.Allocator, _: usize) Money { return .{ .cents = rng.intRangeAtMost(u32, 0, 100_00) }; // $0-$100 } }.f, .shrinkFn = &struct { fn f(_: Money, _: std.mem.Allocator) @import("zigcheck").ShrinkIter(Money) { return @import("zigcheck").ShrinkIter(Money).empty(); } }.f, }; }; // zigcheck.auto(Money) uses zigcheck_gen instead of deriving from fields ``` ### Custom string generators Need strings from a specific character set (e.g. valid identifiers)? Compose `element` with `sliceOfRange`: ```zig const ident_chars = "abcdefghijklmnopqrstuvwxyz0123456789_"; const identGen = comptime zigcheck.sliceOfRange( zigcheck.element(u8, ident_chars), 1, 12, ); // Generates strings of 1-12 chars from ident_chars, with full shrinking ``` This works because `element(u8, charset)` is a `Gen(u8)` and `sliceOfRange` builds a `Gen([]const u8)` from any `Gen(u8)`. You get slice shrinking (removing chunks) plus element shrinking (toward earlier characters in the charset) for free. ### Pattern: spec-then-build for roundtrip testing Generate a lightweight spec struct, then derive the actual test input from it. The spec is what zigcheck generates and shrinks; the real input is constructed from it. This produces readable counterexamples — you see spec fields, not raw strings: ```zig const DefnSpec = struct { ret_type: []const u8, fn_name: []const u8, arity: u8 }; test "parse-emit roundtrip" { try zigcheck.forAll(DefnSpec, defnSpecGen(), struct { fn prop(spec: DefnSpec) !void { const source = buildSource(spec, std.testing.allocator) orelse return; defer std.testing.allocator.free(source); const output = parseAndEmit(source, std.testing.allocator) orelse return error.PropertyFalsified; defer std.testing.allocator.free(output); // assert on output using spec fields as ground truth } }.prop); } ``` ## Installation Add zigcheck as a Zig package dependency in your `build.zig.zon`: ```zig .dependencies = .{ .zigcheck = .{ .url = "https://github.com/phiat/zigcheck/archive/v0.5.5.tar.gz", // .hash = "...", // zig build will tell you the expected hash }, }, ``` Then in `build.zig`: ```zig const zigcheck_dep = b.dependency("zigcheck", .{}); const zigcheck_mod = zigcheck_dep.module("zigcheck"); // Add to your test step: my_tests.root_module.addImport("zigcheck", zigcheck_mod); ``` ## Generators ### Primitive types | Generator | Type | Description | |---|---|---| | `generators.int(T)` | `Gen(T)` | Size-scaled integer (small at size 0, full range at max) | | `generators.intRange(T, min, max)` | `Gen(T)` | Integer in `[min, max]` | | `generators.float(T)` | `Gen(T)` | Size-scaled finite float | | `generators.boolean()` | `Gen(bool)` | `true` or `false` | | `generators.byte()` | `Gen(u8)` | Single byte (alias for `int(u8)`) | | `generators.positive(T)` | `Gen(T)` | Strictly positive integer (`> 0`) | | `generators.nonNegative(T)` | `Gen(T)` | Non-negative integer (`>= 0`) | | `generators.nonZero(T)` | `Gen(T)` | Non-zero integer (`!= 0`) | | `generators.negative(T)` | `Gen(T)` | Strictly negative integer (`< 0`, signed only) | ### Slices and strings | Generator | Type | Description | |---|---|---| | `slice(T, gen, max_len)` | `Gen([]const T)` | Slice of `T` with length in `[0, max_len]` | | `sliceRange(T, gen, min, max)` | `Gen([]const T)` | Slice with length in `[min, max]` | | `asciiChar()` | `Gen(u8)` | Printable ASCII (32-126) | | `asciiString(max_len)` | `Gen([]const u8)` | ASCII string up to `max_len` | | `asciiStringRange(min, max)` | `Gen([]const u8)` | ASCII string with length in `[min, max]` | | `alphanumeric()` | `Gen(u8)` | `[a-zA-Z0-9]` | | `alphanumericString(max_len)` | `Gen([]const u8)` | Alphanumeric string | | `string(max_len)` | `Gen([]const u8)` | Raw bytes (any u8) | | `unicodeChar()` | `Gen(u21)` | Random Unicode code point (excludes surrogates) | | `unicodeString(max_cps)` | `Gen([]const u8)` | Valid UTF-8 string up to `max_cps` code points | Slice shrinking removes chunks (halves, quarters, eighths, ..., single elements), then shrinks individual elements. The runner uses an internal arena for generated values, so no special allocator setup is needed. > **`slice` vs `sliceOf`:** `slice(T, gen, max)` and `sliceRange(T, gen, min, max)` require an explicit element type. `sliceOf(gen, max)` and `sliceOfRange(gen, min, max)` infer it from the generator. Prefer `sliceOf`/`sliceOfRange` — they're shorter and less error-prone. **Tip:** Use `sliceOfRange(gen, 1, max)` when your property needs at least one element — `sliceOf(gen, max)` can shrink to empty, which may cause `for (1..s.len)` to overflow. ```zig try zigcheck.forAll([]const u8, zigcheck.asciiString(50), struct { fn prop(s: []const u8) !void { // test your parser, serializer, etc. _ = s; } }.prop); ``` ### Derived types | Generator | Type | Description | |---|---|---| | `auto(T)` | `Gen(T)` | Auto-derive from `int`, `float`, `bool`, `enum`, `struct`, `?T`, `[]const T`, `union(enum)`. See [Struct generators](#struct-generators) for the full decision tree. | | `Gen(T).fromGenFn(fn)` | `Gen(T)` | Construct a generator from just a generation function, with no shrinking. | ### Combinators | Combinator | Signature | Description | |---|---|---| | `constant(T, value)` | `Gen(T)` | Always produces `value` | | `element(T, choices)` | `Gen(T)` | Picks from a fixed list | | `oneOf(T, gens)` | `Gen(T)` | Picks from multiple generators | | `frequency(T, weighted)` | `Gen(T)` | Weighted choice from `{weight, gen}` pairs | | `map(A, B, gen, fn)` | `Gen(B)` | Transform output type (no shrinking; use `shrinkMap`) | | `mapAlloc(A, B, gen, fn)` | `Gen(B)` | Like `map` but `fn` receives the arena allocator | | `filter(T, gen, pred)` | `Gen(T)` | Retry up to 1000 times; logs warning if exhausted. Prefer `assume()` for restrictive predicates. | | `flatMap(A, B, gen, fn)` | `Gen(B)` | Monadic bind for dependent generation (no shrinking) | | `noShrink(T, gen)` | `Gen(T)` | Disable shrinking for a generator | | `shrinkMap(A, B, gen, fwd, bwd)` | `Gen(B)` | Shrink via isomorphism | | `shrinkMapAlloc(A, B, gen, fwd, bwd)` | `Gen(B)` | Like `shrinkMap` but `fwd`/`bwd` receive the arena allocator | | `sized(T, factory)` | `Gen(T)` | Generator from size-dependent factory function | | `resize(T, gen, size)` | `Gen(T)` | Override size parameter to a fixed value | | `scale(T, gen, pct)` | `Gen(T)` | Scale size parameter by percentage | | `mapSize(T, gen, fn)` | `Gen(T)` | Transform size parameter with a function | | `suchThatMap(A, B, gen, fn)` | `Gen(B)` | Filter and transform in one step | | `funGen(A, B, gen_b)` | `Gen(FunWith(A,B,gen_b))` | Generate random pure functions (QuickCheck `Fun`) | | `build(T, gens)` | `Gen(T)` | **Recommended for structs.** Per-field generators with independent shrinking. See [Struct generators](#struct-generators). | | `zip(gens)` | `Gen(Tuple)` | Combine generators into a tuple `Gen(struct { A, B, ... })` | | `arrayOf(T, gen, N)` | `Gen([N]T)` | Fixed-size array with per-element shrinking | | `zipMap(gens, R, fn)` | `Gen(R)` | Zip generators + map with splatted args | | `sliceOf(gen, max)` | `Gen([]const T)` | Infers element type from generator (preferred over `slice`) | | `sliceOfRange(gen, min, max)` | `Gen([]const T)` | Infers element type from generator (preferred over `sliceRange`) | | `GenType(gen)` | `type` | Extract the value type `T` from a `Gen(T)` | ```zig // Only test with positive even numbers const pos_even = comptime zigcheck.filter(i32, zigcheck.generators.int(i32), struct { fn pred(n: i32) bool { return n > 0 and @mod(n, 2) == 0; } }.pred); // Weighted choice: 90% small, 10% large const weighted = comptime zigcheck.frequency(u32, &.{ .{ 9, zigcheck.generators.intRange(u32, 0, 10) }, .{ 1, zigcheck.generators.intRange(u32, 1000, 10000) }, }); ``` ### Composed combinator examples Combinators compose — chain `map`, `filter`, `shrinkMap`, and `flatMap` to build domain-specific generators from primitives. **map + filter: domain-constrained types** ```zig // Generate valid HTTP port numbers (1024-65535), mapped to a Port struct const Port = struct { value: u16 }; const portGen = comptime zigcheck.map( u16, Port, zigcheck.filter(u16, zigcheck.generators.int(u16), struct { fn pred(n: u16) bool { return n >= 1024; } }.pred), struct { fn f(n: u16) Port { return .{ .value = n }; } }.f, ); ``` **shrinkMap: isomorphic domain types with shrinking** ```zig // Generate timestamps as u64, shrink in integer space, convert both ways const Timestamp = struct { epoch_ms: u64 }; const timestampGen = comptime zigcheck.shrinkMap( u64, Timestamp, zigcheck.generators.intRange(u64, 0, 4_102_444_800_000), // up to 2100 struct { fn fwd(ms: u64) Timestamp { return .{ .epoch_ms = ms }; } }.fwd, struct { fn bwd(ts: Timestamp) u64 { return ts.epoch_ms; } }.bwd, ); ``` **flatMap: dependent generation** ```zig // Generate a length, then a string of exactly that length const exactLenString = comptime zigcheck.flatMap( u8, []const u8, zigcheck.generators.intRange(u8, 1, 20), struct { fn f(len: u8) zigcheck.Gen([]const u8) { return zigcheck.sliceOfRange(zigcheck.generators.alphanumeric(), len, len); } }.f, ); ``` **suchThatMap: filter + transform in one step** ```zig // Generate non-empty strings and return their first character const firstChar = comptime zigcheck.suchThatMap( []const u8, u8, zigcheck.generators.asciiString(50), struct { fn f(s: []const u8) ?u8 { return if (s.len > 0) s[0] else null; // null = discard } }.f, ); ``` ### Collection generators | Generator | Signature | Description | |---|---|---| | `shuffle(T, items)` | `Gen([]const T)` | Random permutation of a fixed list | | `sublistOf(T, items)` | `Gen([]const T)` | Random subsequence of a fixed list | | `orderedList(T, gen, max)` | `Gen([]const T)` | Sorted slice of random values | | `growingElements(T, items)` | `Gen(T)` | Biased toward earlier elements | ## Shrinking Every generator comes with a built-in shrinker that converges toward a minimal counterexample: | Type | Strategy | |---|---| | Integer | Binary search toward zero; try sign flip for negatives | | `intRange` | Binary search toward `min`, clamped to `[min, max]` | | Bool | `true` shrinks to `false` | | Float | Yield `0.0`, then halve toward zero (handles NaN/Inf) | | Enum | Yield variants with lower declaration index | | Struct | Shrink each field independently | | Slice | Remove chunks (halves, quarters, ..., single elements), then shrink elements | | `element` | Shrink toward earlier elements in the list | | `filter` | Inner shrinker, filtered by predicate | The runner uses an arena allocator for shrink state, freed in bulk when shrinking completes. Enable `.verbose_shrink = true` to see each shrink step. ## Implication / preconditions Use `assume()` to discard test cases that don't meet preconditions. The runner tracks discards and gives up if too many are discarded (default: 500): ```zig test "division is inverse of multiplication" { try zigcheck.forAllZip(.{ zigcheck.generators.int(i32), zigcheck.generators.int(i32), }, struct { fn prop(a: i32, b: i32) !void { try zigcheck.assume(b != 0); // discard when b is zero const result = @divTrunc(a *% b, b); if (result != a) return error.PropertyFalsified; } }.prop); } ``` ## Coverage / labeling Track the distribution of generated test cases with `forAllLabeled`: ```zig try zigcheck.forAllLabeled(i32, zigcheck.generators.int(i32), struct { fn prop(n: i32) !void { if (n == 0) return error.PropertyFalsified; } }.prop, struct { fn classify(n: i32) []const []const u8 { if (n > 0) return &.{"positive"}; if (n < 0) return &.{"negative"}; return &.{"zero"}; } }.classify, ); // Prints: 50.2% positive, 49.7% negative, 0.1% zero ``` Coverage requirements (`forAllCover` and `PropertyContext.cover`) use **Wilson score confidence intervals** to avoid spurious failures. A label only fails when the upper bound of the confidence interval is below the required percentage, meaning the library is statistically confident the true proportion is insufficient. The confidence level defaults to 0.95 and can be set via `Config.confidence`. ## Configuration ```zig try zigcheck.forAllWith(.{ .num_tests = 500, // default: 100 .max_shrinks = 2000, // default: 1000 .max_discard = 1000, // default: 500 .seed = 0x2a, // default: null (time-based) .verbose = true, // default: false .verbose_shrink = true, // default: false .max_size = 200, // default: 100 .confidence = 0.99, // default: 0.95 }, i32, gen, property); ``` Use `.seed` for deterministic, reproducible test runs. Failed tests print their seed so you can replay them. The runner uses an internal arena for generated values, so no special allocator setup is needed for slice/string generators. Use `.max_discard` to control how many test cases can be discarded via `assume()` before giving up. Use `.confidence` to set the confidence level for coverage checks (see below). Config supports builder methods for per-property overrides (QuickCheck's `withMaxSuccess`, `withMaxShrinks`, etc.): ```zig const cfg = (Config{}).withNumTests(500).withMaxShrinks(2000).withSeed(0x2a); try zigcheck.forAllWith(cfg, i32, gen, property); ``` Properties that need to allocate working memory should use `std.testing.allocator` directly. The runner's internal arena is for generated values only and is not exposed to property functions. ## Size parameter Like QuickCheck, zigcheck threads a `size` parameter (0 to `max_size`, default 100) linearly across test cases. Early tests use small values, later tests use large ones. This helps find both small-value edge cases and large-value stress bugs in a single run. All generators respect size: - **int(T)** and **float(T)** scale their range — at size 0 they produce `0`, at max size they produce full-range values - **Slice/string generators** scale their maximum length — at size 0 they generate minimum-length values, at max size they generate up to the configured maximum - **intRange**, **boolean**, **element**, **enum** ignore size (their range is already constrained) Use `resize(T, gen, n)` to pin a generator to a fixed size, `scale(T, gen, pct)` to multiply the size by a percentage, or `sized(T, factory)` to build a generator whose behavior depends on the current size. Set `max_size` in Config to change the upper bound. ## API ### Core | Function | Description | |---|---| | `forAll(T, gen, property)` | Run property check with default config | | `forAllWith(config, T, gen, property)` | Run with explicit config | | `forAllZip(gens, property)` | N-argument property with splatted args | | `forAllZipWith(config, gens, property)` | N-argument property with explicit config | | `check(config, T, gen, property)` | Return `CheckResult` without failing | | `recheck(T, gen, property, result)` | Replay a failed `CheckResult` (QuickCheck `recheck`) | | `fromFuzzInput(T, gen, bytes, allocator)` | Generate one structured value from fuzz bytes | | `checkFuzzOne(T, gen, bytes, property)` | Fuzz one-shot check, returns `FuzzCheckResult` (no logging) | | `forAllFuzzOne(T, gen, bytes, property)` | Fuzz one-shot check with logging (use inside `std.testing.fuzz`) | ### Property helpers | Function | Description | |---|---| | `assume(condition)` | Discard test case if condition is false | | `assertEqual(T, expected, actual)` | Assert equality with diagnostic output | | `counterexample(fmt, args)` | Log context before a property failure | | `expectFailure(T, gen, property)` | Pass only if the property fails | | `forAllLabeled(T, gen, property, classifier)` | Collect coverage statistics | | `forAllLabeledWith(config, T, gen, property, classifier)` | Labeled check with explicit config | | `checkLabeled(config, T, gen, property, classifier, alloc)` | Return `CheckResultLabeled` without failing | | `forAllCover(config, T, gen, prop, classifier, reqs)` | Labeled check with minimum coverage requirements (Wilson score) | | `forAllCollect(config, T, gen, property)` | Auto-label with stringified value (QuickCheck `collect`) | | `forAllTabulate(config, T, gen, prop, table, classifier)` | Group labels under a named table (QuickCheck `tabulate`) | | `conjoin(config, T, gen, properties)` | All properties must hold (`.&&.`) | | `disjoin(config, T, gen, properties)` | At least one must hold (`.||.`) | | `within(T, timeout_us, property)` | Fail if property takes longer than limit (QuickCheck `within`) | | `forAllCtx(T, gen, property)` | Property with `PropertyContext` for composable classify/cover/label | | `forAllCtxWith(config, T, gen, property)` | Context property with explicit config | ### Stateful testing Test stateful APIs by generating random command sequences and verifying model invariants (QuickCheck's `Test.QuickCheck.Monadic` / Erlang QuickCheck's `eqc_statem`): ```zig const Command = union(enum) { push: i32, pop }; const Model = struct { size: usize = 0 }; const Spec = zigcheck.StateMachine(Command, Model, *MyStack); try Spec.runWith(.{ .num_tests = 100, .max_commands = 30 }, .{ .init_model = initModel, .init_sut = initStack, .gen_command = genCmd, .precondition = precond, .run_command = runCmd, .next_model = nextModel, .postcondition = postCond, }); ``` If your SUT holds resources (sockets, arenas, file handles), pass `.cleanup_sut = myCleanupFn` to free them after each test sequence. Failing sequences are automatically shrunk in two phases: first by removing chunks of commands (same strategy as slice shrinking), then by shrinking individual command payloads (e.g., `push(1000)` shrinks to `push(0)`). This produces truly minimal counterexamples. ### Utility | Function | Description | |---|---| | `sample(T, gen, n, allocator)` | Generate N sample values for debugging | | `sampleWith(T, gen, n, seed, allocator)` | Sample with specific seed | ## Running tests ```bash zi
↗ GitHub