Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Testing with Channels

EffectChannel is the test-oriented adapter.

It gives the app an implementation of a capability trait and gives the test a handler that can receive pending requests:

#![allow(unused)]
fn main() {
let (random, random_handler) = EffectChannel::unbounded();
let (render, render_handler) = EffectChannel::unbounded();
let logger = EffectSink::unbounded();

let app = App::new(random, render, logger);
}

EffectChannel::unbounded() and EffectSink::unbounded() make the queue policy explicit. Unbounded queues are fine for tests, scripted hosts, tools, and small apps where pending effects are naturally limited. If a host needs backpressure, use EffectChannel::bounded(capacity) or EffectSink::bounded(capacity). A capacity of 0 creates a rendezvous channel where the app-side effect call and handler-side receive must meet.

When the app awaits an effect, the future stays pending until the test answers it:

#![allow(unused)]
fn main() {
let mut update = Box::pin(app.update(Event::Counter(counter::Event::Random)));

assert_pending!(&mut update);

random_handler.handle_get_number(async || 42).await.unwrap();

assert_pending!(&mut update);

render_handler.handle_render(async || {
    assert_eq!(app.view().counter, "42");
}).await.unwrap();

assert_ready!(&mut update);
}

This is more precise than a traditional mock. The test controls when the effect is observed, what it returns, and what state is visible before the original update resumes.

Script Scenarios

At the same time usually mocks are more static, or require to create sequences of network responses beforehand. Things like “first we will return 500 and then return 200 to simulate temporary network failure” are way more natural with the channel approach. We do control timeline and can test advanced scenarios.

The handler receives real pending requests. That means a test can script a sequence:

#![allow(unused)]
fn main() {
let mut status_codes = vec![200, 500].into_iter();

network_handler
    .handle_request(async |request| {
        assert_eq!(request.path, "/profile");
        HttpResponse {
            status: status_codes.next().unwrap(),
            body: String::new(),
        }
    })
    .await
    .unwrap();
}

The first request can return success and the second can return failure. The app does not know it is talking to a mock. It is awaiting the same capability trait it would use in production.

Out-of-Order Completion

Each PendingEffect contains its own response sender:

#![allow(unused)]
fn main() {
let first = handler.next().await;
let second = handler.next().await;

second.respond_async(second_output).await.unwrap();
first.respond_async(first_output).await.unwrap();
}

The response resumes the exact call that produced that pending effect. There is no effect ID map for the test to maintain.

Ignoring Effects

Sometimes a test cares about one capability and wants to ignore another. EffectSink is useful for unit-returning effects such as logging:

#![allow(unused)]
fn main() {
let logger = EffectSink::unbounded();
}

Unit effects can be discarded because they do not feed a value back into app computation. Value-returning effects still need a channel or a real implementation because the app is waiting for a result. If you intentionally use EffectSink for a mixed effect, use sink.handler() to handle value-returning requests.

Failure And Cancellation

EffectChannel exposes shutdown as typed errors through its fallible methods. The fail-fast generated trait adapters still panic if those errors reach them, so production code that needs to recover should use the lower-level fallible APIs.

If the app future waiting on an effect is dropped, the pending request remains visible to the handler, but answering it returns ChannelError::ResponseReceiverDropped:

#![allow(unused)]
fn main() {
let pending = handler.next().await.unwrap();
drop(update);

assert_eq!(
    pending.respond(output).unwrap_err(),
    ChannelError::ResponseReceiverDropped,
);
}

If a PendingEffect is dropped without a response, the app-side try_recv(...).await returns ChannelError::ResponseSenderDropped. If every handler receiver is dropped, app-side send returns ChannelError::RequestReceiverDropped. If every app-side sender is dropped, handler-side receive returns ChannelError::HandlerQueueClosed.