Skip to content

Commit

Permalink
Merge pull request #316 from redbadger/examples
Browse files Browse the repository at this point in the history
  • Loading branch information
StuartHarris authored Feb 17, 2025
2 parents fafe4c1 + 83a3ffd commit c57ce16
Show file tree
Hide file tree
Showing 21 changed files with 337 additions and 308 deletions.
9 changes: 9 additions & 0 deletions crux_core/src/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ use crate::{
/// AppTester is a simplified execution environment for Crux apps for use in
/// tests.
///
/// Please note that the AppTester is strictly no longer required now that Crux
/// has a new [`Command`] API. To test apps without the AppTester, you can call
/// the `update` method on your app directly, and then inspect the effects
/// returned by the command. For examples of how to do this, consult any of the
/// [examples in the Crux repository](/~https://github.com/redbadger/crux/tree/master/examples).
/// The AppTester is still provided for backwards compatibility, and to allow you to
/// migrate to the new API without changing the tests,
/// giving you increased confidence in your refactor.
///
/// Create an instance of `AppTester` with your `App` and an `Effect` type
/// using [`AppTester::default`].
///
Expand Down
266 changes: 97 additions & 169 deletions docs/src/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,50 +96,46 @@ 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](/~https://github.com/redbadger/crux/blob/master/examples/notes/shared/src/app.rs#L379)
from the
Let's take a really simple test from the
[Notes example app](/~https://github.com/redbadger/crux/tree/master/examples/notes)
and walk through it step by step — the test replaces some selected text in a
document and checks that the correct text is rendered.

The first thing to do is create an instance of the `AppTester` test harness,
which runs our app (`NoteEditor`) and makes it easy to analyze the `Event`s and
`Effect`s that are generated.

```rust,ignore,no_run
let app = AppTester::<NoteEditor, _>::default();
{{#include ../../../examples/notes/shared/src/app.rs:replaces_selection_and_renders}}
```

The `Model` is normally private to the app (`NoteEditor`), but `AppTester`
allows us to set it up for our test. In this case the document contains the
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.

```rust,ignore,no_run
let mut model = Model {
note: Note::with_text("hello"),
cursor: TextCursor::Selection(3..5),
..Default::default()
};
```

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).

```rust,ignore,no_run
let event = Event::Insert("ter skelter".to_string());
let update = app.update(event, &mut model);
let mut cmd = app.update(event, &mut model);
```

````admonish
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.
```rust,ignore
{{#include ../../../examples/notes/shared/src/app.rs:update}}
```
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!`](https://docs.rs/crux_core/latest/crux_core/macro.assert_effect.html)
macro, which panics if none of the effects generated by the update matches the
specified pattern.

```rust,ignore,no_run
assert_effect!(update, Effect::Render(_));
assert_effect!(cmd, Effect::Render(_));
```

Finally we can ask the app for its `ViewModel` and use it to check that the text
Expand All @@ -154,56 +150,42 @@ assert_eq!(view.cursor, TextCursor::Position(14));

## Writing a more complicated test

Now let's take a
[more complicated test](/~https://github.com/redbadger/crux/blob/master/examples/notes/shared/src/app.rs#L630)
and walk through that. 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). 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 the `AppTester`
test harness, which runs our app (`NoteEditor`) and makes it easy to analyze the
`Event`s and `Effect`s that are generated.

Now let's take a more complicated test and walk through that.
```rust,ignore,no_run
let app = AppTester::<NoteEditor, _>::default();
```

We again need to set up a `Model` that we can pass to the `update()` method.

```rust,ignore,no_run
let mut model = Model {
note: Note::with_text("hello"),
cursor: TextCursor::Selection(2..4),
..Default::default()
};
{{#include ../../../examples/notes/shared/src/app.rs:starts_a_timer_after_an_edit}}
```
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`](https://crates.io/crates/crux_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 `Timer` Capability, mapping them to their
inner `Request<TimerOperation>` type.
`Effect`s that were created by the `Time` Capability, mapping them to their
inner `Request<TimeRequest>` type.

```rust,ignore,no_run
let requests = &mut app
.update(Event::Insert("something".to_string()), &mut model)
.into_effects()
.filter_map(Effect::into_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);
```

There are a few things to discuss here. Firstly, the `update()` method returns
an `Update` struct, which contains vectors of `Event`s and `Effect`s. We are
only interested in the `Effect`s, so we call `into_effects()` to consume them as
an `Iterator` (there are also `effects()` and `effects_mut()` methods that allow
us to borrow the `Effect`s instead of consuming them, but we don't need that
here). Secondly, we use the `filter_map()` method to filter out just the
`Effect`s that were created by the `Timer` Capability, using
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<TimerOperation>`.
`Request<TimeRequest>`.

The [`Effect`](/~https://github.com/redbadger/crux/tree/master/crux_macros) derive
macro generates filters and maps for each capability that we are using. So if
Expand All @@ -214,7 +196,7 @@ our `Capabilities` struct looked like this...
#[cfg_attr(feature = "typegen", derive(crux_core::macros::Export))]
#[derive(Effect)]
pub struct Capabilities {
timer: Timer<Event>,
timer: Time<Event>,
render: Render<Event>,
pub_sub: PubSub<Event>,
key_value: KeyValue<Event>,
Expand All @@ -236,23 +218,19 @@ 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 `Start` operation, and that the
timer is set to fire in 1000 milliseconds. The macro
[`assert_let!()`](https://docs.rs/assert_let_bind/0.1.1/assert_let_bind/) does a
pattern match for us and assigns the `id` to a local variable called `first_id`,
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.

```rust,ignore,no_run
// this is mutable so we can resolve it later
let request = &mut requests.next().unwrap();
assert_let!(
TimerOperation::Start {
id: first_id,
millis: 1000
},
request.operation.clone()
);
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());
```

Expand All @@ -261,128 +239,78 @@ There are other ways to analyze effects from the update.
You can take all the effects that match a predicate out of the update:
```rust,ignore,no_run
let requests = update.take_effects(|effect| effect.is_timer());
// or
let requests = update.take_effects(Effect::is_timer);
```
Or you can partition the effects into those that match the predicate and those
that don't:
```rust,ignore,no_run
// split the effects into HTTP requests and renders
let (timer_requests, other_requests) = update.take_effects_partitioned_by(Effect::is_timer);
```
There are also `expect_*` methods that allow you to assert and return a certain
type of effect:
```rust,ignore,no_run
// this is mutable so we can resolve it later
let request = &mut timer_requests.pop_front().unwrap().expect_timer();
assert_eq!(
request.operation,
TimerOperation::Start { id: 1, millis: 1000 }
);
assert!(timer_requests.is_empty());
```
````

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 `Timer` capability falls into the
"request/response" category, so we need to resolve the `Start` request with a
`Created` response. This tells the app that the timer has been started, and
allows it to cancel the timer if necessary.
```rust,ignore,no_run
let mut requests = cmd2.effects().filter(|effect| effect.is_timer());
// or
let mut requests = cmd2.effects().filter(Effect::is_timer);
```
Note that resolving a request could call the app's `update()` method resulting
in more `Event`s being generated, which we need to feed back into the app.
Or you can filter and map at the same time:
```rust,ignore,no_run
let update = app.resolve(request, TimerOutput::Created { id: first_id }).unwrap();
for event in update.events {
let _ = app.update(event, &mut model);
}
// or, if this event "settles" the app
let _updated = app.resolve_to_event_then_update(
request,
TimerOutput::Created { id: first_id },
&mut model
);
let mut requests = cmd2.effects().filter_map(Effect::into_timer);
```
Before the timer fires, we'll insert another character, which should cancel the
existing timer and start a new one.
There are also `expect_*` methods that allow you to assert and return a certain
type of effect:
```rust,ignore,no_run
let mut requests = app
.update(Event::Replace(1, 2, "a".to_string()), &mut model)
.into_effects()
.filter_map(Effect::into_timer);
let cancel_request = requests.next().unwrap();
assert_let!(
TimerOperation::Cancel { id: cancel_id },
cancel_request.operation
);
assert_eq!(cancel_id, first_id);
let request = cmd.expect_one_effect().expect_render();
```
````

let start_request = &mut requests.next().unwrap(); // this is mutable so we can resolve it later
assert_let!(
TimerOperation::Start {
id: second_id,
millis: 1000
},
start_request.operation.clone()
);
assert_ne!(first_id, second_id);
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.

assert!(requests.next().is_none());
```
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.

Now we need to tell the app that the second timer was created.
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`).

```rust,ignore,no_run
let update = app
.resolve(start_request, TimerOutput::Created { id: second_id })
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();
for event in update.events {
app.update(event, &mut model);
}
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 `Finished` response.
resolve our start request again, but this time with a `DurationElapsed` response.

```rust,ignore,no_run
let update = app
.resolve(start_request, TimerOutput::Finished { id: second_id })
start_request
.resolve(TimeResponse::DurationElapsed { id: second_id })
.unwrap();
for event in update.events {
app.update(event, &mut model);
}
```

Another edit should result in another timer, but not in a cancellation:

```rust,ignore,no_run
let update = app.update(Event::Backspace, &mut model);
let mut requests = update.into_effects().filter_map(Effect::into_timer);
assert_let!(
TimerOperation::Start {
id: third_id,
millis: 1000
},
requests.next().unwrap().operation
);
assert!(requests.next().is_none()); // no 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);
```
Expand Down
Loading

0 comments on commit c57ce16

Please sign in to comment.