Skip to main 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 runs the IO through the default runtime and checks the resulting Outcome.

MatcherPasses when
ioSucceeded([matcher])IO completes with a value; optional matcher checked against the value
ioErrored([matcher])IO raises an error; optional matcher checked against the error
ioCanceled()IO is canceled before completing
expectIO(actual, matcher)Like expectLater, but returns IO<Unit> for chaining inside IO programs
void matchersTests() {
// ioSucceeded asserts the IO completes with a value matching the given matcher.
test('IO succeeds with 42', () {
expect(IO.pure(42), ioSucceeded(42));
});

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

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

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

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

Composing with standard matchers

ioSucceeded and ioErrored 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.

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

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

// ioErrored also accepts a matcher for the raised error.
test('error message contains keyword', () {
final io = IO.raiseError<Unit>('connection refused: timeout');
expect(io, ioErrored(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.

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
void tickerTests() {
// .ticked wraps an IO in a Ticker backed by a deterministic virtual clock.
// nonTerminating() fast-forwards all pending timers and returns true if
// the IO has still not completed — useful for asserting deadlocks.
test('IO waiting on an unset Deferred never terminates', () {
final d = Deferred.unsafe<int>();
expect(d.value().ticked.nonTerminating(), isTrue);
});

// tickAll() advances through every scheduled sleep without waiting real time.
// The IO below would take 10 hours on a real clock; with Ticker it is instant.
test('IO.sleep fast-forwards with tickAll', () async {
final ticker = IO.sleep(10.hours).productR(() => IO.pure('woke up')).ticked..tickAll();

await expectLater(ticker.outcome, completion(Outcome.succeeded('woke up')));
});
}

nonTerminating() is the standard way to assert that an IO is permanently stuck — for example, verifying that acquiring a semaphore with zero permits truly suspends rather than proceeding or erroring.


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.

/// 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', () async {
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.
// With Ticker, tickAll() advances all virtual sleeps instantaneously.
final ticker = pollUntil(mockCheck).ticked..tickAll();

await expectLater(ticker.outcome, completion(Outcome.succeeded(5)));
expect(attempts, 5);
});
}

The cascade ..tickAll() advances the virtual clock through all four one-second sleeps in sequence. 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.