Skip to content

Effect Matchers

ribs_test provides matchers and a deterministic runtime for testing IO programs. The matchers integrate with package:test's expect/expectLater and work with both live futures and the virtual-time Ticker.

dart
import 'package:ribs_test/ribs_effect_test.dart';

Outcome matchers

Every IO<A> computation finishes in one of three outcomes: it succeeds with a value, raises an error, or is canceled. There is a matcher for each.

succeeds([matcher])

Runs the IO and asserts it completes successfully. Pass a matcher to also validate the result value.

errors([matcher])

Asserts the IO raises an error. Optionally validates the thrown value.

cancels

Asserts the IO is canceled before it completes. Use as a constant (no call needed).

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);
  });
}

All matchers accept any package:test Matcher for further validation:

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')));
  });
}

Termination matchers

Sometimes you want to assert whether an IO ever completes rather than what value it produces. These matchers use a TestIORuntime internally — they fast-forward all pending timers and check whether the fiber has finished.

terminates

Asserts the IO completes in finite (virtual) time — regardless of whether it succeeds, errors, or is canceled.

nonTerminating

Asserts the IO does not complete even after all pending work is drained.


Virtual time with Ticker

Real IO.sleep calls block for wall-clock time, making tests slow and non-deterministic. Ticker pairs an IO with a TestIORuntime whose clock only advances when you explicitly tell it to, making sleep-based tests instantaneous.

Any matcher that accepts an IO<A> also accepts a Ticker<A>. Obtain one via the .ticked extension:

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);
  });
}

Manual time control

When you need fine-grained control — for example, to assert intermediate states between time steps — construct a Ticker explicitly and advance the clock yourself. Call tick() first to run the fiber's startup step and register its first scheduled task before advancing time:

dart
void manualTickerTest() {
  test('step-by-step time control', () async {
    int count = 0;
    final io = IO.sleep(1.second).productR(IO.delay(() => ++count)).replicate_(3);
    final ticker = io.ticked;

    // tick() lets the fiber run its startup step and schedule the first sleep.
    ticker.tick();
    expect(count, 0); // the sleep hasn't fired yet

    ticker.advanceAndTick(1.second); // first sleep fires
    expect(count, 1);

    ticker.advanceAndTick(1.second); // second sleep fires
    expect(count, 2);

    ticker.advanceAndTick(1.second); // third sleep fires
    expect(count, 3);

    expect(await ticker.outcome, Outcome.succeeded(Unit()));
  });
}
Ticker methodWhat it does
tick()Runs all tasks that are currently due
tickOne()Runs the single earliest due task; returns true if one ran
tickAll()Drains the queue — advances time and ticks until nothing remains
advance(d)Moves the clock forward by d without running any tasks
advanceAndTick(d)Advances by d then runs all newly-due tasks
nonTerminating()Calls tickAll() and returns true if the fiber is still running
nextInterval()Duration until the next scheduled task
outcomeFuture<Outcome<A>> — completes when the fiber finishes

Real-world example: testing a poller

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);
  });
}

expectIO

When you need to assert inside a running IO program rather than at the top-level, expectIO lifts an expectLater call into IO:

dart
void expectIOTest() {
  // expectIO lifts an expectLater call into IO, so assertions compose inside
  // an IO program. Useful when testing concurrent behavior.
  test('assert inside an IO program', () {
    final test = IO
        .both(
          IO.pure(1).delayBy(100.milliseconds),
          IO.pure(2).delayBy(200.milliseconds),
        )
        .flatMap((pair) => expectIO(pair, (1, 2)));

    expect(test, succeeds());
  });
}

TestIORuntime

TestIORuntime is the runtime that powers Ticker. You can also construct one directly when a test needs access to the runtime itself (e.g., to check autoCedeN):

dart
final runtime = TestIORuntime();
io.unsafeRunAsync((oc) => ..., runtime: runtime);
runtime.tickAll();

TestIORuntime exposes the same tick / advance / tickAll API as Ticker. The autoCedeN constructor parameter controls how many steps run before an automatic cooperative yield, matching IORuntime.defaultRuntime.autoCedeN.