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.
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).
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:
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:
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:
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 method | What 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 |
outcome | Future<Outcome<A>> — completes when the fiber finishes |
Real-world example: testing a poller
/// 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:
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):
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.