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.