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 accepts either a plain IO (run on the real runtime) or a Ticker (run on the virtual clock — see Ticker below).
| Matcher | Passes 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 |
cancels | IO is canceled before completing |
terminates | IO eventually completes (succeeds, errors, or cancels) |
nonTerminating | IO does not complete after all pending timers are exhausted |
expectIO(actual, matcher) | Like expectLater, but returns IO<Unit> for chaining inside IO programs |
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.
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:
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:
// 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 / 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() {
// 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.
/// 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.