A guide to testing Crux apps
Introduction
One of the most compelling consequences of the Crux architecture is that it becomes trivial to comprehensively test your application. This is because the core is pure and therefore completely deterministic — all the side effects are pushed to the shell.
It's straightforward to write an exhaustive set of unit tests that give you complete confidence in the correctness of your application code — you can test the behavior of your application independently of platform-specific UI and API calls.
There is no need to mock/stub anything, and there is no need to write integration tests.
Not only are the unit tests easy to write, but they run extremely quickly, and can be run in parallel.
For example, the Notes example app contains complex logic related to collaborative text-editing using Conflict-free Replicated Data Types (CRDTs). The test suite consists of 25 tests that give us high coverage and high confidence of correctness. Many of the tests include instantiating two instances (alice and bob) and checking that, even during complex edits, the synchronization between them works correctly.
This test, for example, ensures that when Alice and Bob both insert text at the same time, they both end up with the same result. It runs in 4 milliseconds.
#[test]
fn two_way_sync() {
let (mut alice, mut bob) = make_alice_and_bob();
alice.update(Event::Insert("world".to_string()));
let edits = alice.edits.drain(0..).collect::<Vec<_>>();
bob.send_edits(edits.as_ref());
// Alice's inserts should go in front of Bob's cursor
// so we break the ambiguity of same cursor position
// as quickly as possible
bob.update(Event::Insert("Hello ".to_string()));
let edits = bob.edits.drain(0..).collect::<Vec<_>>();
alice.send_edits(edits.as_ref());
let alice_view = alice.view();
let bob_view = bob.view();
assert_eq!(alice_view.text, "Hello world".to_string());
assert_eq!(alice_view.text, bob_view.text);
}
And the full suite of 25 tests runs in 16 milliseconds.
cargo nextest run --release -p shared
Finished release [optimized] target(s) in 0.07s
Starting 25 tests across 2 binaries
PASS [ 0.005s] shared app::editing_tests::handles_emoji
PASS [ 0.005s] shared app::editing_tests::removes_character_before_cursor
PASS [ 0.005s] shared app::editing_tests::moves_cursor
PASS [ 0.006s] shared app::editing_tests::inserts_text_at_cursor_and_renders
PASS [ 0.005s] shared app::editing_tests::removes_selection_on_backspace
PASS [ 0.005s] shared app::editing_tests::removes_character_after_cursor
PASS [ 0.005s] shared app::editing_tests::removes_selection_on_delete
PASS [ 0.007s] shared app::editing_tests::changes_selection
PASS [ 0.006s] shared app::editing_tests::renders_text_and_cursor
PASS [ 0.006s] shared app::editing_tests::replaces_empty_range_and_renders
PASS [ 0.005s] shared app::editing_tests::replaces_range_and_renders
PASS [ 0.005s] shared app::note::test::splices_text
PASS [ 0.005s] shared app::editing_tests::replaces_selection_and_renders
PASS [ 0.004s] shared app::save_load_tests::opens_a_document
PASS [ 0.005s] shared app::note::test::inserts_text
PASS [ 0.005s] shared app::save_load_tests::saves_document_when_typing_stops
PASS [ 0.005s] shared app::save_load_tests::starts_a_timer_after_an_edit
PASS [ 0.006s] shared app::save_load_tests::creates_a_document_if_it_cant_open_one
PASS [ 0.005s] shared app::sync_tests::concurrent_clean_edits
PASS [ 0.005s] shared app::sync_tests::concurrent_conflicting_edits
PASS [ 0.005s] shared app::sync_tests::one_way_sync
PASS [ 0.005s] shared app::sync_tests::remote_delete_moves_cursor
PASS [ 0.005s] shared app::sync_tests::remote_insert_behind_cursor
PASS [ 0.004s] shared app::sync_tests::two_way_sync
PASS [ 0.005s] shared app::sync_tests::receiving_own_edits
------------
Summary [ 0.016s] 25 tests run: 25 passed, 0 skipped
Writing a simple test
Crux provides a simple test harness that we can use to write unit tests for our application code. Strictly speaking it's not needed, but it makes it easier to avoid boilerplate and to write tests that are easy to read and understand.
Let's take a really simple test from the Notes example app and walk through it step by step — the test replaces some selected text in a document and checks that the correct text is rendered.
#[test]
fn replaces_selection_and_renders() {
let app = NoteEditor::default();
let mut model = Model {
note: Note::with_text("hello"),
cursor: TextCursor::Selection(3..5),
..Default::default()
};
let event = Event::Insert("ter skelter".to_string());
let mut cmd = app.update(event, &mut model);
assert_effect!(cmd, Effect::Render(_));
let view = app.view(&model);
assert_eq!(view.text, "helter skelter".to_string());
assert_eq!(view.cursor, TextCursor::Position(14));
}
The first thing to do is create an instance of our app (NoteEditor
) and set it up
with a model for our test. In this case the document contains the
string "hello"
with the last two characters selected.
Let's insert some text under the selection range. We simply create an Event
that captures the user's action and pass it into the app's update()
method,
along with the Model we just created (which we will be able to inspect
afterwards).
let event = Event::Insert("ter skelter".to_string());
let mut cmd = app.update(event, &mut model);
The update()
method we called above does not take a Capabilities
argument.
It is actually our own update()
method that we delegate to in the NoteEditor
app.
fn update(
&self,
event: Self::Event,
model: &mut Self::Model,
_caps: &Self::Capabilities,
) -> Command<Effect, Event> {
// delegate to our own update method for testing. This will not be necessary
// once the `App` trait has been modified to remove the `caps` parameter.
self.update(event, model)
}
Once the migration to the new Command
API is complete, the signature of this method
wil be changed in the App
trait and this delegation will no longer be required.
We can check that the shell was asked to render by using the
assert_effect!
macro, which panics if none of the effects generated by the update matches the
specified pattern.
assert_effect!(cmd, Effect::Render(_));
Finally we can ask the app for its ViewModel
and use it to check that the text
was inserted correctly and that the cursor position was updated.
let view = app.view(&model);
assert_eq!(view.text, "helter skelter".to_string());
assert_eq!(view.cursor, TextCursor::Position(14));
Writing a more complicated test
Now let's take a more complicated test and walk through that.
#[test]
fn starts_a_timer_after_an_edit() {
let app = NoteEditor::default();
let mut model = Model {
note: Note::with_text("hello"),
cursor: TextCursor::Selection(2..4),
..Default::default()
};
// An edit should trigger a timer
let event = Event::Insert("something".to_string());
let mut cmd1 = app.update(event, &mut model);
let mut requests = cmd1.effects().filter_map(Effect::into_timer);
let request = requests.next().unwrap();
let (first_id, duration) = match &request.operation {
TimeRequest::NotifyAfter { id, duration } => (id.clone(), duration),
_ => panic!("expected a NotifyAfter"),
};
assert_eq!(duration, &Duration::from_secs(1).unwrap());
assert!(requests.next().is_none());
drop(requests); // so we can use cmd1 later
// Before the timer fires, insert another character, which should
// cancel the timer and start a new one
let mut cmd2 = app.update(Event::Replace(1, 2, "a".to_string()), &mut model);
let mut requests = cmd2.effects().filter_map(Effect::into_timer);
// but first, the original request (cmd1) should resolve with a clear
let cancel_request = cmd1
.effects()
.filter_map(Effect::into_timer)
.next()
.unwrap();
let cancel_id = match &cancel_request.operation {
TimeRequest::Clear { id } => id.clone(),
_ => panic!("expected a Clear"),
};
assert_eq!(cancel_id, first_id);
// request to start the second timer
let mut start_request = requests.next().unwrap();
let second_id = match &start_request.operation {
TimeRequest::NotifyAfter { id, duration: _ } => id.clone(),
_ => panic!("expected a NotifyAfter"),
};
assert_ne!(first_id, second_id);
assert!(requests.next().is_none());
// Time passes
start_request
.resolve(TimeResponse::DurationElapsed { id: second_id })
.unwrap();
// One more edit. Should result in a timer, but not in cancellation
let mut cmd3 = app.update(Event::Backspace, &mut model);
let mut timer_requests = cmd3.effects().filter_map(Effect::into_timer);
let start_request = timer_requests.next().unwrap();
let third_id = match &start_request.operation {
TimeRequest::NotifyAfter { id, duration: _ } => id.clone(),
_ => panic!("expected a NotifyAfter"),
};
assert!(timer_requests.next().is_none());
assert_ne!(third_id, second_id);
}
This test checks that a "save" timer is restarted each
time the user edits the document (after a second of no activity the document is
stored). We will use the Time
capability to manage this. Note that the actual timer is run by the shell
(because it is a side effect, which would make it really tricky to test) —
but all we need to do is check that the behavior of the timer is correct
(i.e. started, finished and cancelled correctly).
Again, the first thing we need to do is create an instance of our app (NoteEditor
),
supply a model to represent our starting state, and analyze the
Event
s and Effect
s that are generated.
We send an Event
(e.g. raised in response to a user action) into our app in
order to check that it does the right thing.
Here we send an Insert event, which should start a timer. We filter out just the
Effect
s that were created by the Time
Capability, mapping them to their
inner Request<TimeRequest>
type.
let event = Event::Insert("something".to_string());
let mut cmd1 = app.update(event, &mut model);
let mut requests = cmd1.effects().filter_map(Effect::into_timer);
There are a few things to discuss here. Firstly, the update()
method returns
a Command
, which gives us access to the Event
s and Effect
s. We are
only interested in the Effect
s, so we call effects()
to consume them as
an Iterator
. Secondly, we use the filter_map()
method to filter out just the
Effect
s that were created by the Time
Capability, using
Effect::into_timer
to map the Effect
s to their inner
Request<TimeRequest>
.
The Effect
derive
macro generates filters and maps for each capability that we are using. So if
our Capabilities
struct looked like this...
#[cfg_attr(feature = "typegen", derive(crux_core::macros::Export))]
#[derive(Effect)]
pub struct Capabilities {
timer: Time<Event>,
render: Render<Event>,
pub_sub: PubSub<Event>,
key_value: KeyValue<Event>,
}
... we would get the following filters and filter_maps:
// filters
Effect::is_timer(&self) -> bool
Effect::is_render(&self) -> bool
Effect::is_pub_sub(&self) -> bool
Effect::is_key_value(&self) -> bool
// filter_maps
Effect::into_timer(self) -> Option<Request<TimeRequest>>
Effect::into_render(self) -> Option<Request<RenderOperation>>
Effect::into_pub_sub(self) -> Option<Request<PubSubOperation>>
Effect::into_key_value(self) -> Option<Request<KeyValueOperation>>
We want to check that the first request is a NotifyAfter
operation, and that the
timer is set to fire in 1000 milliseconds. So let's do a
pattern match and assign the id
to a local variable called first_id
,
which we'll use later. Finally, we don't expect any more timer requests to have
been generated.
let request = requests.next().unwrap();
let (first_id, duration) = match &request.operation {
TimeRequest::NotifyAfter { id, duration } => (id.clone(), duration),
_ => panic!("expected a NotifyAfter"),
};
assert_eq!(duration, &Duration::from_secs(1).unwrap());
assert!(requests.next().is_none());
There are other ways to analyze effects from the update.
You can take all the effects that match a predicate out of the update:
let mut requests = cmd2.effects().filter(|effect| effect.is_timer());
// or
let mut requests = cmd2.effects().filter(Effect::is_timer);
Or you can filter and map at the same time:
let mut requests = cmd2.effects().filter_map(Effect::into_timer);
There are also expect_*
methods that allow you to assert and return a certain
type of effect:
let request = cmd.expect_one_effect().expect_render();
At this point the shell would start the timer (this is something the core can't do as it is a side effect) and so we need to tell the app that it was created. We do this by "resolving" the request.
Remember that Request
s either resolve zero times (fire-and-forget, e.g. for
Render
), once (request/response, e.g. for Http
), or many times (for streams,
e.g. Sse
— Server-Sent Events). The Time
capability falls into the
"request/response" category, so at some point, we should resolve the NotifyAfter
request with a DurationElapsed
response.
However, before the timer fires, we'll insert another character, which should cancel the
existing timer (still on cmd1
) and start a new one (on cmd2
).
let mut cmd2 = app.update(Event::Replace(1, 2, "a".to_string()), &mut model);
let mut requests = cmd2.effects().filter_map(Effect::into_timer);
// but first, the original request (cmd1) should resolve with a clear
let cancel_request = cmd1
.effects()
.filter_map(Effect::into_timer)
.next()
.unwrap();
let cancel_id = match &cancel_request.operation {
TimeRequest::Clear { id } => id.clone(),
_ => panic!("expected a Clear"),
};
assert_eq!(cancel_id, first_id);
In the real world, time passes and the timer fires, but all we have to do is to
resolve our start request again, but this time with a DurationElapsed
response.
start_request
.resolve(TimeResponse::DurationElapsed { id: second_id })
.unwrap();
Another edit should result in another timer, but not in a cancellation:
// One more edit. Should result in a timer, but not in cancellation
let mut cmd3 = app.update(Event::Backspace, &mut model);
let mut timer_requests = cmd3.effects().filter_map(Effect::into_timer);
let start_request = timer_requests.next().unwrap();
let third_id = match &start_request.operation {
TimeRequest::NotifyAfter { id, duration: _ } => id.clone(),
_ => panic!("expected a NotifyAfter"),
};
assert!(timer_requests.next().is_none());
assert_ne!(third_id, second_id);
Note that this test was not about testing whether the model was updated
correctly (that is covered in other tests) so we don't call the app's view()
method — it's just about checking that the timer is started, cancelled and
restarted correctly.