Property-Based Testing
ribs_check is Ribs' property-based testing library, inspired by ScalaCheck.
Instead of writing individual test cases with hand-picked inputs, you describe the properties your code must satisfy and let the ribs_check generate hundreds of random inputs automatically — then minimise any failing input to the simplest possible counterexample.
Motivation
Consider testing a function that reverses a list. The obvious approach is to pick a few inputs:
test('reverse', () {
expect(reverse([1, 2, 3]), equals([3, 2, 1]));
expect(reverse([]), equals([]));
});This only covers two cases. With property-based testing you describe the invariants that must hold for any input:
- Reversing a list twice gives back the original list.
- A reversed list has the same length as the original.
ribs_check generates 100 (or more) random inputs automatically and checks each one. If any input fails, it shrinks the failing value down to the smallest counterexample before reporting the error.
Your first property
Gen<A> is a generator of random values of type A. Call .forAll on any Gen to register a property as a test:
void basicTests() {
// Gen.integer generates random ints across the full 32-bit range.
// .forAll registers a test that runs the body 100 times by default.
Gen.integer.forAll('abs is non-negative', (int n) {
expect(n.abs(), greaterThanOrEqualTo(0));
});
// Built-in string generators cover common character sets.
Gen.alphaLowerString().forAll('lowercase string is already lowercased', (String s) {
expect(s.toLowerCase(), equals(s));
});
// List generators: sizeGen controls how many elements are produced.
Gen.ilistOf(Gen.chooseInt(0, 20), Gen.integer).forAll(
'reversing twice is identity',
(IList<int> xs) {
expect(xs.reverse().reverse(), equals(xs));
},
);
}Every Gen.forAll call is equivalent to calling test(...) from the test package — it integrates directly with the normal test runner.
Built-in generators
Combining generators
Gen forms a Monad — map and flatMap let you build complex generators from simple ones. A Dart record of generators provides a multi-parameter forAll that receives each value as a separate named argument.
void combiningTests() {
// A tuple of generators passes each value as a separate parameter to forAll.
(Gen.integer, Gen.integer).forAll('addition is commutative', (int a, int b) {
expect(a + b, equals(b + a));
});
// map transforms the generated value before handing it to the test.
Gen.positiveInt.map((int n) => n * 2).forAll('doubling a positive int gives an even number', (
int n,
) {
expect(n % 2, equals(0));
});
// flatMap creates *dependent* generators — the second Gen uses the value
// from the first. This generates a (lo, hi) pair that always satisfies lo <= hi.
final rangeGen = Gen.chooseInt(-100, 100).flatMap(
(int lo) => Gen.chooseInt(lo, 100).map((int hi) => (lo, hi)),
);
rangeGen.forAll('generated range always has lo <= hi', ((int, int) t) {
expect(t.$1, lessThanOrEqualTo(t.$2));
});
// frequency picks between generators with weighted probability.
// Here: 10% None, 90% Some with a positive int.
Gen.option(Gen.positiveInt).forAll('option gen produces mostly Some', (Option<int> opt) {
// Just checking the type is correct; no assertion about which variant.
opt.fold(() => null, (int n) => expect(n, greaterThan(0)));
});
}Key points:
maptransforms the generated value without consuming new randomness.flatMapproduces a newGenfrom the generated value — essential for dependent generators where one value constrains the range of another (therangeGenexample above).- Tuple syntax
(Gen<A>, Gen<B>).forAll(desc, (A a, B b) { ... })runs two independent generators and passes each result as a separate argument. Tuples up to arity 22 are supported. Gen.frequencyassigns integer weights to generators; each pair is(weight, gen).Gen.retryUntil(p)re-samples until the predicate holds — use sparingly (up to 1000 retries; preferflatMapfor dependent structure).
Custom generators
For domain types, combine generators with .tupled and .map:
enum Priority { low, medium, high }
final class Task {
final String name;
final Priority priority;
final int estimateHours;
const Task(this.name, this.priority, this.estimateHours);
@override
String toString() => 'Task($name, $priority, ${estimateHours}h)';
}
// Build a Gen<Task> by combining three generators with .tupled and .map.
final Gen<Task> genTask = (
Gen.nonEmptyAlphaNumString(20),
Gen.chooseEnum(Priority.values),
Gen.chooseInt(1, 40),
).tupled.map((t) => Task(t.$1, t.$2, t.$3));
void customGenTests() {
genTask.forAll('task estimate is always within range', (Task task) {
expect(task.estimateHours, greaterThanOrEqualTo(1));
expect(task.estimateHours, lessThanOrEqualTo(40));
});
// Gen.ilistOf propagates the element shrinker automatically — if the
// property fails for a long list, ribs_check will try shorter lists first.
Gen.ilistOf(Gen.chooseInt(1, 8), genTask).forAll(
'total estimate for a non-empty task list is positive',
(IList<Task> tasks) {
final total = tasks.foldLeft(0, (int acc, Task t) => acc + t.estimateHours);
expect(total, greaterThan(0));
},
);
}(gen1, gen2, gen3).tupled produces a Gen<(A, B, C)> whose shrinker is automatically composed from the shrinkers of each component. Any Gen built from these primitives inherits shrinking for free.
Shrinking
When a property fails, ribs_check does not just report the first failing input. It shrinks — repeatedly tries smaller variants of the failing value until it finds the minimal input that still causes the failure.
void shrinkingTests() {
// Shrink candidates can be inspected directly via gen.shrink(value).
// Built-in shrinkers for Int drive values toward 0.
test('integer shrinker produces candidates closer to 0', () {
final candidates = Gen.integer.shrink(100).toList();
expect(candidates, contains(0));
expect(candidates.every((int n) => n.abs() < 100), isTrue);
});
// String shrinker removes characters — a failing string is minimised to the
// shortest string that still triggers the failure.
test('string shrinker produces shorter candidates', () {
final candidates = Gen.alphaLowerString().shrink('hello').toList();
expect(candidates.every((String s) => s.length < 'hello'.length), isTrue);
});
// IList shrinker removes elements, then shrinks individual elements.
test('ilist shrinker removes elements', () {
final xs = ilist([10, 20, 30, 40]);
final candidates = Gen.ilistOf(Gen.chooseInt(0, 10), Gen.integer).shrink(xs).toList();
expect(candidates.any((IList<int> c) => c.length < xs.length), isTrue);
});
}Shrinkers are built into the standard generators:
| Generator | Shrinks toward |
|---|---|
Gen.integer / Gen.chooseInt | 0 (bisects the distance) |
Gen.chooseDouble | 0.0 (bisects the distance) |
Gen.alphaLowerString / Gen.alphaNumString | Shorter strings |
Gen.ilistOf / Gen.ilistOfN | Shorter lists, then smaller elements |
Gen.option | None, then smaller Some value |
Gen.either | Smaller Left/Right value |
Custom generators built with map, flatMap, and tupled compose their shrinkers automatically. If you need full control, call .withShrinker(Shrinker(fn)) on any Gen to replace its shrinker.
Seeds and reproducibility
By default, ribs_check seeds each run from the current timestamp so every run exercises different inputs. When a failure is found, the seed is printed so you can reproduce it exactly:
Failed after 7 iterations using value <-42> and initial seed of [1706123456789].
To reproduce this failure, use seed: 1706123456789 in your forAllN/Prop call, or run:
RIBS_CHECK_SEED=1706123456789 dart test --plain-name "my property description"
void seedTests() {
// Pass seed: to lock the random sequence — the same seed always produces
// the same inputs in the same order. Useful for reproducing a CI failure.
Gen.integer.forAll(
'fixed seed produces a deterministic sequence',
(int n) => expect(n, isNotNull),
seed: 12345,
);
// numTests: controls the sample size (default 100).
Gen.alphaLowerString().forAll(
'run with 50 samples',
(String s) => expect(s, isA<String>()),
numTests: 50,
);
// When a property fails without an explicit seed, ribs_check prints:
//
// Failed after 7 iterations using value <-42>.
// Initial seed: [1706123456789]
// To reproduce: RIBS_CHECK_SEED=1706123456789 dart test --plain-name "..."
//
// Set RIBS_CHECK_SEED in the environment to replay a failing run without
// changing the test source.
}| Option | Effect |
|---|---|
seed: n | Lock the RNG seed for this property |
numTests: n | Number of inputs to generate (default 100) |
RIBS_CHECK_SEED=n env var | Override the seed for all properties in the run |