A compact inventory of the public API, for people and agents writing
tests. The library README explains the ideas; rustdoc
(cargo doc -p ephyra --open) has every item. Names not listed here
usually don't exist: see Things that do not exist.
use ephyra::prelude::*; // Gen, GenExt, Source, Config, check, target, assume!, ...
use ephyra::{gens, check_shrinker, Run};A generator is any Fn(&mut Source) -> T + Send + Sync, or a value from
gens::*. Every generator owns a shrinker and a mutator.
Source (seeded; use no other randomness in a generator):
g.int(0u32..=9) g.int(..) g.index(len) g.bool() g.bool_with(0.2)
g.pick(&[a, b]) g.weighted(&[(3, a), (1, b)]) g.f64() g.f64_bits()
g.size() g.draw(&other_gen) g.discard("reason")Constructors in gens:
| Constructor | Notes |
|---|---|
int(range) |
i8..i64, isize, u8..u64, usize. No i128/u128: draw an i64 and widen. |
edges(range) |
Only lo, hi, lo+1, hi-1, 0, 1, -1; mix with int through frequency. |
bool(), bool_with(p) |
|
f64(), f32(), f64_bits(), f32_bits(), f64_range(a..=b) |
f64() is uniform in [0, 1) only. f64_bits() draws a uniform bit pattern: NaN about 1 in 2,000, infinities and zeros practically never; add them with frequency and constant(f64::INFINITY). |
char(), ascii_char(), alphanumeric() |
char() is any scalar value, biased to ASCII. |
string(), string_len(chars, range) |
|
vec(elem), then .len(range) |
One element generator. |
option(g), result(ok, err) |
|
constant(v), pick_from(vec![..]) |
|
one_of(vec![g.boxed(), ..]), frequency(vec![(w, g.boxed()), ..]) |
Call .boxed() on the result for a BoxGen. |
recursive(depth, leaf, |inner| ..) |
A bounded recursive type. |
sized(|size| gen) |
Has no shrinker. |
dependent(outer, |a| inner) |
Returns (A, B). |
tuples (g1, g2, g3), arrays |
Generators too. |
ephyra::any::<T>() |
The Arbitrary default for std types and derived types. |
Size. At iteration i the size is floor(log2(i + 1)), capped at
max_size (100). With the default 10,000 tests it never exceeds 13, so
vec(x) and string() without an explicit length stay short. Write
.len(0..=N) or string_len(c, 0..=N) when long inputs matter.
Combinators (GenExt):
| Combinator | Keeps shrink and mutate? | For |
|---|---|---|
.map(f) |
No: shrinks nothing, mutation regenerates | Cheap reshaping, then .with_shrink |
.and_then(f) |
No | Rarely; prefer dependent |
.iso(forward, back) |
Yes, through a bijection | Newtypes and wrappers |
.dependent(|a| gen_b) |
Yes; returns (A, B) |
A query over a schema, an index into a length |
.filter("why", pred) |
Filters candidates and mutants too; discards after 100 misses | Rare exclusions; construct valid values instead |
.with_shrink(f) |
Adds candidates, tried first | A shrinker on the final type |
.with_mutate(f) |
Replaces the mutator | A custom mutator |
.collect::<C>() |
Yes | Vec<(K, V)> into a map |
.boxed() |
Yes | Storing generators, type erasure |
A shrinker on the final type, the usual fix when a report says
shrinking: 0 of 0 candidates:
use std::ops::ControlFlow;
fn shrink_expr(e: &Expr, visit: &mut dyn FnMut(Expr) -> ControlFlow<()>) -> ControlFlow<()> {
if let Expr::Bin(op, a, b) = e {
visit((**a).clone())?; // a child replaces the node
visit((**b).clone())?;
shrink_expr(a, &mut |a2| visit(Expr::Bin(*op, Box::new(a2), b.clone())))?;
shrink_expr(b, &mut |b2| visit(Expr::Bin(*op, a.clone(), Box::new(b2))))?;
}
ControlFlow::Continue(())
}
let exprs = expr_gen.with_shrink(shrink_expr);A shrinker makes only strictly smaller candidates, never its input, and
propagates ?. #[ephyra::shrinker] turns an eager fn(&T) -> Vec<T> into
one.
Derive:
#[derive(Clone, Debug, ephyra::Arbitrary)]
enum Op {
Pop, // the simplest variant first
Push(#[ephyra(gen = gens::int(0i32..=99))] i32),
Get(#[ephyra(gen = gens::int(0usize..=9))] usize),
}
// gens::vec(ephyra::any::<Op>()).len(0..=40)Recursive derived types have no depth bound; use gens::recursive.
Verifying generators: check_generator, check_shrinker and check_mutator
run through check, so they shrink and replay like a property. Mark a
generator and its support predicate with #[ephyra::generator(pred, metrics = f)] and ephyra check (the CLI) verifies all of them.
A property takes the generated value and returns (), bool,
Result<_, E: Debug> or TestResult. A panic anywhere in it, including in
the code under test, is a failure, with its message and location.
- Prefer
()withassert!andassert_eq!(which prints both values). - Skip inputs that don't apply with
ephyra::assume!(cond); too many discards end the run withGaveUp. - Shrinking keeps "the same failure": the same kind and, for a panic, the
same file and line. Give each check its own
assert!line so shrinking never drifts from one bug to another. AnErrorTestResult::failfrom two different checks counts as the same failure. ephyra::target(score)inside a property steers the targeted search toward higher scores;ephyra::cover(cond, label)checks that a share of inputs has a property.- Not caught: stack overflow,
abort, segmentation faults, infinite loops, out-of-memory. Run code that can do these in a child process with a timeout.Config::timeout_mslimits the whole run, not one test.
let config = Config::default()
.tests(20_000) // passing tests to require
.seed(7) // a reproducible run
.budget(Duration::from_secs(600)) // stop the random phase early
.name("decode_never_panics") // the regressions file's name
.no_regressions(); // don't save or replay .ephyra/regressions
let report = check(&config, generator, property);
report.assert_passed();The attribute form gives one #[test] per property:
#[ephyra::test(gen = pairs(), tests = 5000)] // also: seed = N, max_size = N,
fn decode_inverts_encode(p: Vec<(String, String)>) { // config = expr, search = expr
assert_eq!(decode(&encode(&p)), Ok(p));
}Environment variables (no recompilation):
| Variable | Effect |
|---|---|
EPHYRA_TESTS, EPHYRA_SEED, EPHYRA_BUDGET_MS, EPHYRA_MAX_SIZE |
The budget and the seed |
EPHYRA_REPLAY=seed:iteration |
Test that one input first; the report prints the line |
EPHYRA_FAILURES=N |
Collect up to N distinct failures instead of stopping at the first |
EPHYRA_SHRINK=0 |
Report the original failing input |
EPHYRA_REGRESSIONS=0 |
Don't save or replay remembered failures |
EPHYRA_REPORT_JSON=path |
Append each report as a JSON line |
EPHYRA_COVERAGE_HTML=dir |
Write the coverage grid page (instrumented builds) |
A failing input is saved to .ephyra/regressions/<name>.jsonl under the
working directory and replayed first next time; a failure at "iteration 0"
on a rerun may be that replay. Delete the file, or set
EPHYRA_REGRESSIONS=0, after fixing the bug.
By default a run stops at the first failure. To collect several distinct bugs from one long search:
let config = Config::default().failures(10); // or EPHYRA_FAILURES=10
#[ephyra::test(gen = cases(), failures = 10)] // the attribute formThe run goes on until it has n distinct failures (or the budget ends).
Each new failure is shrunk while keeping its signature, remembered as a
regression, and reported with its own reproduce: line; repeats are only
counted. report.failures lists them all (signature, count, failure);
report.failure is still the first.
A failure's signature, first match wins:
ephyra::signature("display not reparsable: SHOW")called inside the property for that case;Config::signature(|f: &FailureInfo| ...)(f.kind,f.message,f.location,f.value);- the default: the panic location
file:line:col,returned false, or theErrmessage with numbers and quoted values blanked.
Give each kind of check its own assert! line, or name it with
signature(), so different bugs don't merge. Manual Run loops keep going
past failures too (while run.wants_more()); only the first failure is
localized when coverage is on.
ephyra: FAILED, property panicked: assertion `left == right` failed: step 4: Get(1)
left: Some(0)
right: Some(1)
at tests/model.rs:36:31
counterexample: (2, [Push(0), Push(0), Pop, Push(1), Get(1)])
original: (2, [Get(8), Push(59), ... 32 ops]) (size 0, iteration 0)
shrinking: 13 of 179 candidates accepted in 0.6 ms
reproduce: EPHYRA_REPLAY=5713660488102024575:0
In code: report.status (Passed, Failed, GaveUp, TimedOut,
Aborted), report.passed(), report.counterexample(), report.failure
(kind, message, location, original, shrunk, replay, flaky),
report.tests, report.iterations, report.discarded, report.labels,
report.health, report.to_json().
Health warnings worth acting on (report.health[i].kind):
| Kind | Meaning |
|---|---|
flaky |
The failure didn't reproduce on a rerun, so it wasn't shrunk: look for state, time or threads |
shrink_budget |
Shrinking stopped early; raise config.shrink.max_steps |
cover |
A cover(cond, label) share was below 5% |
coverage_plateau |
Coverage stopped growing: the generator is too narrow |
slow_generation |
Generation dominates the time |
config_env |
An EPHYRA_* variable has a bad value |
check is this loop:
let mut run = Run::new(&config, generator, property);
if !run.remembered() { // replays EPHYRA_REPLAY and saved regressions
while run.wants_more() { // tests, budget, discards, a failure
let Some(input) = run.draw() else { continue };
let tested = run.test(&input); // .passed() .failed() .novelty() .score()
if tested.passed() { run.keep(input.value, &tested); }
}
}
let report = run.finish(); // shrinks, localizes, saves, reportsOther steps: run.mutate(&parent), run.pool_pick(),
run.keep_always(value, score), run.failure(), run.shrink(),
run.reproduces(&candidate), run.shrunk_to(minimal), run.report() and,
with coverage, run.suspects(). examples/custom_loop.rs
is a complete one.
A loop that alternates fresh draws with mutants of inputs that reached new code, through the run's own pool:
let mut mutate = false;
while run.wants_more() {
mutate = !mutate;
let parent = if mutate { run.pool_pick() } else { None };
let input = match &parent { Some(p) => run.mutate(p), None => run.draw() };
let Some(input) = input else { continue };
let tested = run.test(&input);
if tested.failed() { break; }
if tested.novelty() > 0 { run.keep_always(input.value, f64::from(tested.novelty())); }
}Shipped loops (ephyra::loops, examples/shipped_loops.rs)
take (&mut run, &mut pool, fuel): fuzz (inputs reaching new coverage
become seeds; plain random testing without coverage), target (seeds
ranked by target(score)), perf_fuzz (toward slow inputs), and with the
coverage feature localize(&mut run, fuel, patience). fuel counts draws
and mutations and ignores Config::tests; call run.finish() afterwards.
Pools: Fifo::new(), Fifo::with_energy(n), Heap::new(), Filo,
Singleton::new(policy), DoubleQueue::new(). Each seed gets 100 mutations
by default, after which fuzz degrades to fresh draws; seed the pool with
the project's own inputs and raise the energy:
use ephyra::loops::{self, SeedPool, Validity};
let mut pool = loops::Fifo::with_energy(20_000);
pool.invest(fixture, Validity { valid: true, score: 1.0 });
loops::fuzz(&mut run, &mut pool, 200_000);-
features = ["coverage"]on the ephyra dependency. -
-C instrument-coveragefor the whole build, for example in the test crate's.cargo/config.toml:[build] rustflags = ["-C", "instrument-coverage"] [env] LLVM_PROFILE_FILE = { value = "target/profraw/%m_%p.profraw", relative = true }
An exported
RUSTFLAGSoverrides[build] rustflagsand coverage turns off silently: checkecho "$RUSTFLAGS", and if it is set, add the flag there. PointLLVM_PROFILE_FILEaway from the code under test, or build scripts leavedefault_*.profrawfiles in its source directories. -
Say which crate to track when the code under test is another crate (a separate test crate):
config.coverage.filter = "mycrate".into();orEPHYRA_COVERAGE_FILTER=mycrate. Use the crate name with underscores, or a module path (mycrate::parser); it covers the crate's methods too, so don't copy the<...>of demangled method names.#[ephyra::test]defaults to the test crate's own name, which is right for tests inside the project and wrong for a separate test crate: check thecoverage:line of the first report names the crate you meant.
Run with one test thread (RUST_TEST_THREADS=1): counters are
process-global. For a file and line view, set EPHYRA_COVERAGE_HTML=dir (or
use ephyra test, which writes .ephyra/coverage/<property>.html). The
report then shows coverage: 3 of 5 functions in tlv reached, never reached: ... and partly covered: ... (in code,
report.coverage). Add targets to steer at unreached code:
config.coverage.targets.push(ephyra::config::CoverageTarget::new("src/parser.rs", 120..=140)),
or one JSON line per target in .ephyra/targets.jsonl. Fault localization
runs on a failure with coverage on and adds suspects to the report
(report.locate).
cargo install ephyra-cli.
| Command | Does |
|---|---|
ephyra guide |
Prints the guide to writing generators and strategies that pass check |
ephyra check |
Verifies every #[ephyra::generator] and every strategy of a property that can discard: values satisfy the predicate, shrink candidates and mutants stay valid, shrink chains settle, metrics are finite. Exit 0 passed, 1 failed, 2 work left. --list shows what it found. |
ephyra test |
Runs the suite with artifacts in .ephyra/ |
ephyra visualize |
A local page for exploring and rerunning runs |
The CLI instruments builds by default; pass --no-instrument, or set
LLVM_PROFILE_FILE, if that gets in the way.
gens::vec_of,gens::vec_of_len,gens::tuple_vec,gens::bool_.- Integer generators for
i128oru128. - A per-test timeout, or process isolation.
- A public
Report::new. - A driver for subprocesses or servers: write it (spawn, feed input, compare output, with a timeout).
- Automatic seeding from files: read them and
pool.invest(...)them.