Skip to content

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:

dart
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:

dart

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

GeneratorTypeDescription
Gen.integerGen<int>Full 32-bit signed range
Gen.positiveIntGen<int>[1, MaxInt]
Gen.nonNegativeIntGen<int>[0, MaxInt]
Gen.chooseInt(min, max)Gen<int>Range [min, max]
Gen.chooseDouble(min, max)Gen<double>Range [min, max]
Gen.booleanGen<bool>true or false
Gen.alphaLowerCharGen<String>One lowercase letter
Gen.alphaLowerString([n])Gen<String>Up to n lowercase letters
Gen.alphaNumString([n])Gen<String>Up to n alphanumeric chars
Gen.nonEmptyAlphaNumString([n])Gen<String>At least one char
Gen.ilistOf(sizeGen, gen)Gen<IList<A>>Variable-length list
Gen.ilistOfN(n, gen)Gen<IList<A>>Exactly n elements
Gen.option(gen)Gen<Option<A>>10% None, 90% Some
Gen.either(genA, genB)Gen<Either<A,B>>50/50 Left/Right
Gen.chooseEnum(values)Gen<T>Uniformly picks an enum variant
Gen.dateGen<DateTime>Random date (date only)
Gen.dateTimeGen<DateTime>Random date and time
Gen.durationGen<Duration>Random duration
Gen.oneOf(values)Gen<A>Uniformly picks from a list
Gen.frequency(weights)Gen<A>Picks a generator by weight

Combining generators

Gen forms a Monadmap 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.

dart

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:

  • map transforms the generated value without consuming new randomness.
  • flatMap produces a new Gen from the generated value — essential for dependent generators where one value constrains the range of another (the rangeGen example 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.frequency assigns integer weights to generators; each pair is (weight, gen).
  • Gen.retryUntil(p) re-samples until the predicate holds — use sparingly (up to 1000 retries; prefer flatMap for dependent structure).

Custom generators

For domain types, combine generators with .tupled and .map:

dart

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.

dart

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:

GeneratorShrinks toward
Gen.integer / Gen.chooseInt0 (bisects the distance)
Gen.chooseDouble0.0 (bisects the distance)
Gen.alphaLowerString / Gen.alphaNumStringShorter strings
Gen.ilistOf / Gen.ilistOfNShorter lists, then smaller elements
Gen.optionNone, then smaller Some value
Gen.eitherSmaller 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"
dart

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.
}
OptionEffect
seed: nLock the RNG seed for this property
numTests: nNumber of inputs to generate (default 100)
RIBS_CHECK_SEED=n env varOverride the seed for all properties in the run