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_test_effect.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.