Testing
ribs_effect ships test utilities in package:ribs_effect/test.dart:
- IO matchers —
expect-compatible matchers that run anIOand assert its outcome Ticker— a deterministic, virtual-clock runtime that fast-forwardsIO.sleepcalls 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.
| Matcher | Passes 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 / property | Description |
|---|---|
io.ticked | Start 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.outcome | Future<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.