Unit test an actor
Test receive, handle and apply as plain functions with TestContext, without an actor system.
receive is a plain function, so you can call it in a test like any other function: pass a message and a state, and check what it returns. The only argument you cannot build by hand is the ActorContext. The test-util feature provides one with TestContext:
cargo add tellus --dev --features test-utilThe examples on this page test the device and the group of the getting started tutorial.
Check the next state
/// A reading becomes the device's state, without any actor system running.
#[test]
fn a_reading_becomes_the_state() {
let test_context = TestContext::new();
let result = Device.receive(
test_context.context(),
Incoming::Message(DeviceMessage::Record(21.5)),
None,
);
assert_matches!(result, Ok(Control::Continue(Some(21.5))));
}test_context.context() is the ActorContext for the call. receive returns a Result with the Control and the next state. assert_matches! (imported with use std::assert_matches;) checks all of it in one assertion and prints the actual value when the check fails. Its bindings do not leave the macro, though. When a test goes on with the state, let ... else takes the result apart instead, as in Test children and death watch. No actor system runs, and nothing needs to be awaited.
Check what an actor sends
/// A read replies with the last reading: a second `TestContext` stands in for the asker and
/// receives the reply.
#[test]
fn a_read_replies_with_the_last_reading() {
let test_context = TestContext::new();
let mut asker = TestContext::<Option<f64>>::new();
let reply_to = asker.context().reply_to(|last_reading| last_reading);
let result = Device.receive(
test_context.context(),
Incoming::Message(DeviceMessage::Read(reply_to)),
Some(21.5),
);
assert_matches!(result, Ok(Control::Continue(Some(21.5))));
assert_eq!(asker.take_incoming(), [Incoming::Message(Some(21.5))]);
}A reply goes to whoever asked. In a test, a second TestContext stands in for the asker: its reply_to creates the ReplyTo for the message. After the call, take_incoming returns everything that arrived at the asker, in order. Messages an actor sends to itself through context.self_ref() arrive at its own TestContext the same way.
Test children and death watch
/// A group spawns a device for an unknown name and forgets it once it has terminated. Spawning
/// starts a real child actor, hence the Tokio runtime.
#[tokio::test]
async fn a_group_spawns_and_forgets_devices() {
let test_context = TestContext::new();
let Control::Continue(devices) = Group
.receive(
test_context.context(),
Incoming::Message(GroupMessage::Record {
device_name: "left",
degrees: 21.5,
}),
HashMap::new(),
)
.expect("the group cannot fail")
else {
panic!("the group stopped");
};
let device_id = devices["left"].actor_id();
let Control::Continue(devices) = Group
.receive(
test_context.context(),
Incoming::Terminated(device_id),
devices,
)
.expect("the group cannot fail")
else {
panic!("the group stopped");
};
assert!(devices.is_empty());
}context.spawn starts a real child actor, so this test needs a Tokio runtime, here via #[tokio::test]. The test then simulates the child's termination by passing Incoming::Terminated with its ID. To test the watching itself, simulate the watched actor with a second TestContext, watch its self_ref() and call terminate on it. Like a real termination, terminate sends the terminated signal to the watchers.
Test an event-sourced actor
For an event-sourced actor, handle decides and apply changes the state. Both are plain functions too. The examples come from the device of the event-sourced actors tutorial and need the persistence feature as well.
/// A valid reading causes exactly one event, and settling it makes it the state.
#[test]
fn a_reading_is_persisted_and_applied() {
let test_context = TestContext::new();
let effect = Device
.handle(
test_context.context(),
Incoming::Message(Command::Record(21.5)),
&None,
)
.expect("the reading is valid");
assert_eq!(effect.events(), [Recorded(21.5)]);
assert!(!effect.stop_requested());
let settlement = settle(&Device, None, effect);
assert_eq!(settlement.state, Some(21.5));
}Effect tells which events a command causes, via events, and whether it stops the actor, via stop_requested. settle does what tellus does once the events are stored: it applies them to the state in order and runs the continuations.
/// An invalid reading fails the device and causes no event.
#[test]
fn an_invalid_reading_fails() {
let test_context = TestContext::new();
let result = Device.handle(
test_context.context(),
Incoming::Message(Command::Record(f64::NAN)),
&Some(21.5),
);
assert_matches!(result, Err(InvalidReading));
}A rejected command returns the actor's error instead of an Effect, so nothing is persisted. assert_matches! names the expected error, so the test fails on success as well as on any other error.
/// A read causes no event; its reply is a continuation, which `settle` runs.
#[test]
fn a_read_replies_with_the_last_reading() {
let test_context = TestContext::new();
let mut asker = TestContext::<Option<f64>>::new();
let reply_to = asker.context().reply_to(|last_reading| last_reading);
let effect = Device
.handle(
test_context.context(),
Incoming::Message(Command::Read(reply_to)),
&Some(21.5),
)
.expect("a read cannot fail");
assert!(effect.events().is_empty());
settle(&Device, Some(21.5), effect);
assert_eq!(asker.take_incoming(), [Incoming::Message(Some(21.5))]);
}A continuation attached with then runs only during settle, so the asker receives the reply after it.
/// Replay is a fold of `apply` over `init`.
#[test]
fn replay_recovers_the_last_reading() {
let seed = Device.init().expect("the seed cannot fail");
let state = [Recorded(21.5), Recorded(22.0)]
.into_iter()
.fold(seed, |state, event| Device.apply(state, event));
assert_eq!(state, Some(22.0));
}Recovery replays the stored events through apply, starting from init. A fold over a list of events tests exactly that, without a store.
What these tests do not cover
A direct call differs from a running actor in a few ways:
- Nothing supervises the call. An error or a panic reaches the test, and nothing restarts the actor.
- The run loop drops a terminated signal of an actor which is no longer watched. A direct call to
receivesees every signal it is given. - Dropping the
TestContextasks the children to stop, but does not wait for them.
Mailbox delivery, supervision and the ordering guarantees belong to tellus and have tests of their own. A test which spawns a system, sends a message with tell and then checks some result mostly tests those again. It also has to wait for the actor somehow, because tell returns before the message is handled.
Run it
From a clone of the tellus repository:
cargo test --quiet -p tellus --features test-util,persistence --test how_to_unit_test --test how_to_unit_test_event_sourced