proptest-zig
A Property-based Testing Library for Zig
This is a small, Hypothesis-style property-based testing library for Zig with integrated binary-search shrinking.
Ported from Rust proptest
and inspired by Hypothesis (Python)
and Hedgehog.
Before We Start
Most unit tests pick a few example inputs and assert what each should do. A property-based test instead states a rule that should always hold, and the framework throws hundreds of random inputs at it looking for one that breaks the rule.
When the rule breaks, the random input that triggered it is usually big
and messy. The library then shrinks it down to the minimal
counterexample that still fails, so you debug { 0, 0 } instead of a
wall of noise.
You describe inputs with typed generators (called strategies), write the rule as a Zig function, and the runner does the rest.
Project Maturity
This is a very young project. Breaking API changes are likely before 1.0.
Target Zig Version
- Zig 0.16.0 or later
Installation
Fetch the latest release into your build.zig.zon:
zig fetch --save=proptest https://github.com/michaelklishin/proptest-zig/archive/refs/tags/v0.5.0.tar.gz
This adds an entry like:
.dependencies = .{
.proptest = .{
.url = "https://github.com/michaelklishin/proptest-zig/archive/refs/tags/v0.5.0.tar.gz",
.hash = "proptest-0.5.0-_GA2m_WOAQCxB73yCtolGqKufs39NkNg6TJlJHdtZqu0",
},
},
Then in your build.zig:
const proptest_dep = b.dependency("proptest", .{
.target = target,
.optimize = optimize,
});
test_module.addImport("proptest", proptest_dep.module("proptest"));
Quick Start
const std = @import("std");
const pt = @import("proptest");
test "addition is commutative" {
var runner = pt.Runner.initDefault();
defer runner.deinit();
const ints = pt.num.intInRange(i32, -1000, 1000);
const pair = pt.tuple.t2(ints, ints);
try runner.check(std.testing.allocator, pair, struct {
fn run(p: struct { i32, i32 }) !void {
if (p[0] + p[1] != p[1] + p[0]) return error.NotCommutative;
}
}.run);
}
When the property fails, the runner shrinks the failing case to a local minimum and logs both the initial and shrunk counterexample together with the seed needed to reproduce.
Strategies
A strategy describes how to generate values of a given type. Each
strategy carries a value tree that supports binary-search shrinking via
current(), simplify(), complicate(), and an optional deinit().
Numeric
// Inclusive integer range, shrinks toward zero (or `lo` when zero is out of range)
const small = pt.num.intInRange(i32, -50, 50);
// Full type range
const any_u32 = pt.num.int(u32);
// Float range. `pt.num.float(T)` defaults to [-1e6, +1e6] to avoid
// routine overflow into infinity in user predicates.
const positive_floats = pt.num.floatInRange(f64, 0.0, 1e9);
Booleans
const fair = pt.boolean(); // 50/50
const biased = pt.weightedBoolean(0.9); // true ~90% of the time
Constants
const always_42 = pt.just(@as(u32, 42));
Slices, Bytes, ASCII Strings
const ints = pt.collection.slice(pt.num.intInRange(i32, 0, 9), 0, 16);
const blob = pt.collection.bytes(0, 256);
const name = pt.collection.asciiString(1, 32);
Slice shrinking removes elements down to min_len first, then shrinks
each surviving element.
Combinators
map: transform values
fn doubleI32(x: i32) i32 { return x * 2; }
const evens = pt.map(pt.num.intInRange(i32, 0, 100), i32, doubleI32);
The inner value tree continues to shrink in its own value space; the mapping function is applied on every read.
filter: accept or reject values
fn isOdd(x: i32) bool { return @rem(x, 2) != 0; }
const odd = pt.filter(pt.num.intInRange(i32, 0, 100), isOdd);
If the predicate rejects too many values in a row, newTree returns
error.FilterTooRestrictive. The threshold is configurable via
Config.max_filter_rejections (default 65536).
oneOf: weighted union
const flag_or_value = pt.oneOf(i32, .{
.{ @as(u32, 9), pt.just(@as(i32, 0)) },
.{ @as(u32, 1), pt.num.intInRange(i32, 1, 100) },
});
Weights must be unsigned integers (compile-time enforced). All branches
must produce the same Value type. oneOf does not switch branches
during shrinking; the chosen branch shrinks within itself.
Tuples
const pair = pt.tuple.t2(pt.num.intInRange(i32, 0, 10), pt.boolean());
const triple = pt.tuple.t3(strategy_a, strategy_b, strategy_c);
const quad = pt.tuple.t4(strategy_a, strategy_b, strategy_c, strategy_d);
Components shrink left-to-right.
Configuring the Runner
var runner = pt.Runner.initFromSeed(.{
.cases = 1024, // how many random inputs to try
.max_shrink_iters = 8192, // shrink budget
.max_filter_rejections = 1024,
.log_failures = true, // print to stderr on failure
}, 0xdeadbeef);
Runner.initFromSeed is hermetic and ignores environment variables.
Runner.initFromEnvOrEntropy(config) reads PROPTEST_SEED,
PROPTEST_CASES, and PROPTEST_MAX_SHRINK_ITERS from the environment;
unset values fall back to config. Runner.initDefault() is a shorthand
for the env/entropy path with default config.
Inspecting the Counterexample
After check returns error.PropertyFailed, the most recent shrunk
counterexample is available as a formatted string:
const result = runner.check(allocator, strategy, predicate);
try std.testing.expectError(error.PropertyFailed, result);
std.debug.print("minimal failing input: {s}\n", .{runner.lastFailing()});
The string is reset on every check call, so a passing run leaves
lastFailing() empty.
Reproducing a Failure
Failed runs print the seed that produced the counterexample:
property failed at case 17 after 12 shrinks: NotCommutative
shrunk counterexample: { -1, 1 }
initial counterexample: { -847, 23 } (NotCommutative)
reproduce with PROPTEST_SEED=12345
Re-run with the same seed to get the same sequence:
PROPTEST_SEED=12345 zig build test
Environment Variables
| Variable | Effect |
|---|---|
PROPTEST_SEED |
Override the runner’s RNG seed |
PROPTEST_CASES |
Override Config.cases |
PROPTEST_MAX_SHRINK_ITERS |
Override Config.max_shrink_iters (set to 0 to disable shrinking) |
Variables are read by Runner.initDefault and
Runner.initFromEnvOrEntropy. Runner.initFromSeed ignores them.
Writing Custom Strategies
Strategies and value trees are duck-typed via comptime: any value with the right declarations works. Every strategy provides:
pub const Value: type;
pub const Tree: type;
pub fn newTree(self, runner: *pt.Runner, allocator: std.mem.Allocator) !Tree;
Every value tree provides:
pub const Value: type;
pub fn current(self: *Self) Value;
pub fn simplify(self: *Self) bool;
pub fn complicate(self: *Self) bool;
pub fn deinit(self: *Self) void; // optional
simplify returns true if it produced a smaller candidate; complicate
walks back when the runner determines the simplification went too far.
The runner tests current() after every transform.
Building and Testing
# Install Zig
brew install zig
# Build
zig build
# Run the full test suite (no external dependencies)
zig build test
# Generate documentation
zig build docs
Tests are split between in-file test "..." blocks and integration tests
under tests/. The meta-tests in tests/meta_test.zig use proptest-zig
itself to test its own primitives.
License
Dual-licensed under Apache License 2.0 and MIT, matching upstream Rust
proptest. See LICENSE-APACHE and
LICENSE-MIT.
Copyright
(c) 2025-2026 Michael S. Klishin and Contributors.