Skip to content

Testing

ribs_effect ships test utilities in package:ribs_effect/test.dart:

  • IO matchersexpect-compatible matchers that run an IO and assert its outcome
  • Ticker — a deterministic, virtual-clock runtime that fast-forwards IO.sleep calls so time-sensitive tests run instantly

IO Matchers

The matchers integrate directly with package:test's expect. Each matcher accepts either a plain IO (run on the real runtime) or a Ticker (run on the virtual clock — see Ticker below).

MatcherPasses when
succeeds([matcher])IO completes with a value; optional matcher checked against the value
errors([matcher])IO raises an error; optional matcher checked against the error
cancelsIO is canceled before completing
terminatesIO eventually completes (succeeds, errors, or cancels)
nonTerminatingIO does not complete after all pending timers are exhausted
expectIO(actual, matcher)Like expectLater, but returns IO<Unit> for chaining inside IO programs
dart
void matchersTests() {
  // succeeds asserts the IO completes with a value matching the given matcher.
  test('IO succeeds with 42', () {
    expect(IO.pure(42), succeeds(42));
  });

  // succeeds() with no argument only checks that the IO succeeds.
  test('IO succeeds (value unchecked)', () {
    expect(IO.print('hello'), succeeds());
  });

  // errors asserts the IO raises an error.
  test('IO fails with expected error', () {
    expect(IO.raiseError<int>('boom'), errors('boom'));
  });

  // errors() with no argument only checks that the IO errored.
  test('IO fails (error unchecked)', () {
    expect(IO.raiseError<int>(Exception('oops')), errors());
  });

  // cancels asserts the IO was canceled before completing.
  test('IO is canceled', () {
    expect(IO.canceled, cancels);
  });
}

Composing with standard matchers

succeeds and errors accept any Matcher from package:test, so you can compose them with the full set of built-in matchers — greaterThan, isA, contains, hasLength, and so on.

dart
void advancedMatchersTests() {
  // succeeds accepts *any* standard test Matcher for the value.
  test('result satisfies a condition', () {
    expect(IO.pure(ilist([1, 2, 3])).map((xs) => xs.length), succeeds(greaterThan(2)));
  });

  test('result is the right type', () {
    expect(IO.pure(42).map((n) => n.toString()), succeeds(isA<String>()));
  });

  // errors also accepts a matcher for the raised error.
  test('error message contains keyword', () {
    final io = IO.raiseError<Unit>('connection refused: timeout');
    expect(io, errors(contains('timeout')));
  });
}

Ticker

The standard IO matchers run IO on a real runtime. That works for most tests, but it breaks down for IOs that involve IO.sleep: a test for a 10-hour timeout would have to wait 10 hours.

Ticker solves this by running the IO on a virtual clock backed by TestIORuntime. Time only advances when you tell it to — no real wall-clock time ever passes.

The simple path: pass io.ticked to a matcher

All matchers — succeeds, errors, cancels, terminates, nonTerminating — accept a Ticker directly. When they receive one, they call tickAll() automatically before checking the outcome, so you rarely need to manipulate the ticker yourself:

dart
expect(IO.sleep(3.seconds).ticked, succeeds());
expect(IO.sleep(3.seconds).productR(IO.raiseError('boom')).ticked, errors('boom'));
expect(IO.canceled.ticked, cancels);

You can also use io.ticked in place of a plain IO to run any test with the virtual clock:

dart
// Runs in microseconds despite the 5-second sleep.
expect(IO.sleep(5.seconds).productR(IO.pure(42)).ticked, succeeds(42));

Ticker API

For cases where you need finer control — for instance, interleaving timer advances with intermediate assertions — you can operate on the Ticker directly:

Method / propertyDescription
io.tickedStart io on a TestIORuntime and return a Ticker<A>
ticker.tick()Run all tasks scheduled at or before the current virtual time
ticker.tickAll()Advance through every pending sleep and run all tasks to completion
ticker.tickOne()Run exactly one queued task; returns false if none are ready
ticker.advance(d)Move the virtual clock forward by d without running tasks
ticker.advanceAndTick(d)Advance clock by d, then run all newly ready tasks
ticker.nonTerminating()Calls tickAll() then returns true if the IO has not yet completed
ticker.outcomeFuture<Outcome<A>> that completes when the IO finishes
ticker.nextInterval()Duration until the next scheduled task
dart
void tickerTests() {
  // Pass io.ticked directly to any matcher — the matcher calls tickAll()
  // automatically, so virtual time advances and the test completes instantly.
  test('IO.sleep fast-forwards with ticked', () {
    expect(IO.sleep(10.hours).productR(IO.pure('woke up')).ticked, succeeds('woke up'));
  });

  // You can also use timed() to assert how much virtual time elapsed.
  test('IO.sleep takes the right virtual duration', () {
    expect(IO.sleep(3.seconds).timed().map((t) => t.$1).ticked, succeeds(3.seconds));
  });

  // The nonTerminating matcher accepts IO or Ticker directly.
  // It fast-forwards all pending timers and asserts the IO has not completed.
  test('IO waiting on an unset Deferred never terminates', () {
    final d = Deferred.unsafe<int>();
    expect(d.value(), nonTerminating);
  });

  // terminates asserts the IO eventually completes (succeeds, errors, or cancels).
  test('IO.sleep eventually terminates', () {
    expect(IO.sleep(1.second), terminates);
  });
}

Real-world example: testing a polling loop

A common pattern is to poll for external state on a fixed interval — retry an IO until it returns Some, sleeping between attempts. On a real clock, a test for this pattern would take as many seconds as there are retries. With Ticker, every sleep is skipped and the entire retry chain completes in microseconds.

dart
/// Polls [check] repeatedly, sleeping [interval] between attempts, until it
/// returns [Some]. This is a typical pattern for waiting on external state.
IO<A> pollUntil<A>(IO<Option<A>> check, {Duration interval = const Duration(seconds: 1)}) =>
    check.flatMap(
      (result) => result.fold(
        () => IO.sleep(interval).productR(pollUntil(check, interval: interval)),
        IO.pure,
      ),
    );

void realWorldTickerTest() {
  test('pollUntil retries until state is ready', () {
    int attempts = 0;

    // Simulated external check: returns None for the first 4 calls, then Some.
    final mockCheck = IO.delay(() {
      attempts++;
      return attempts >= 5 ? Some(attempts) : none<int>();
    });

    // Without Ticker, this would sleep for 4 seconds.
    // Passing .ticked to succeeds() advances all virtual sleeps instantaneously.
    expect(pollUntil(mockCheck).ticked, succeeds(5));
    expect(attempts, 5);
  });
}

Passing io.ticked to succeeds advances the virtual clock automatically. The test verifies not just the final value, but also that the correct number of attempts were made — something that would be difficult to assert reliably against wall-clock time.