crux_core/
testing.rs

1//! Testing support for unit testing Crux apps.
2use anyhow::Result;
3use std::{collections::VecDeque, sync::Arc};
4
5#[expect(deprecated)]
6use crate::WithContext;
7use crate::{
8    Command, Request, Resolvable,
9    capability::{
10        CommandSpawner, Operation, ProtoContext, QueuingExecutor, channel::Receiver,
11        executor_and_spawner,
12    },
13};
14
15/// `AppTester` is a simplified execution environment for Crux apps for use in
16/// tests.
17///
18/// Please note that the `AppTester` is strictly no longer required now that Crux
19/// has a new [`Command`] API. To test apps without the `AppTester`, you can call
20/// the `update` method on your app directly, and then inspect the effects
21/// returned by the command. For examples of how to do this, consult any of the
22/// [examples in the Crux repository](https://github.com/redbadger/crux/tree/master/examples).
23/// The `AppTester` is still provided for backwards compatibility, and to allow you to
24/// migrate to the new API without changing the tests,
25/// giving you increased confidence in your refactor.
26///
27/// Create an instance of `AppTester` with your `App` and an `Effect` type
28/// using [`AppTester::default`].
29///
30/// for example:
31///
32/// ```rust,ignore
33/// let app = AppTester::<ExampleApp, ExampleEffect>::default();
34/// ```
35pub struct AppTester<App>
36where
37    App: crate::App,
38{
39    app: App,
40    capabilities: App::Capabilities,
41    context: Arc<AppContext<App::Effect, App::Event>>,
42    command_spawner: CommandSpawner<App::Effect, App::Event>,
43}
44
45struct AppContext<Ef, Ev> {
46    commands: Receiver<Ef>,
47    events: Receiver<Ev>,
48    executor: QueuingExecutor,
49}
50
51#[expect(deprecated)]
52impl<App> AppTester<App>
53where
54    App: crate::App,
55{
56    /// Create an `AppTester` instance for an existing app instance. This can be used if your App
57    /// has a constructor other than `Default`, for example when used as a child app and expecting
58    /// configuration from the parent
59    pub fn new(app: App) -> Self
60    where
61        App::Capabilities: WithContext<App::Event, App::Effect>,
62    {
63        Self {
64            app,
65            ..Default::default()
66        }
67    }
68
69    /// Run the app's `update` function with an event and a model state
70    ///
71    /// You can use the resulting [`Update`] to inspect the effects which were requested
72    /// and potential further events dispatched by capabilities.
73    pub fn update(
74        &self,
75        event: App::Event,
76        model: &mut App::Model,
77    ) -> Update<App::Effect, App::Event> {
78        let command = self.app.update(event, model, &self.capabilities);
79        self.command_spawner.spawn(command);
80
81        self.context.updates()
82    }
83
84    /// Resolve an effect `request` from previous update with an operation output.
85    ///
86    /// This potentially runs the app's `update` function if the effect is completed, and
87    /// produce another `Update`.
88    ///
89    /// # Errors
90    ///
91    /// Errors if the request cannot (or should not) be resolved.
92    pub fn resolve<Output>(
93        &self,
94        request: &mut impl Resolvable<Output>,
95        value: Output,
96    ) -> Result<Update<App::Effect, App::Event>> {
97        request.resolve(value)?;
98
99        Ok(self.context.updates())
100    }
101
102    /// Resolve an effect `request` from previous update, then run the resulting event
103    ///
104    /// This helper is useful for the common case where  one expects the effect to resolve
105    /// to exactly one event, which should then be run by the app.
106    ///
107    /// # Panics
108    ///
109    /// Panics if the request cannot be resolved.
110    #[track_caller]
111    pub fn resolve_to_event_then_update<Op: Operation>(
112        &self,
113        request: &mut Request<Op>,
114        value: Op::Output,
115        model: &mut App::Model,
116    ) -> Update<App::Effect, App::Event> {
117        request.resolve(value).expect("failed to resolve request");
118        let event = self.context.updates().expect_one_event();
119        self.update(event, model)
120    }
121
122    /// Run the app's `view` function with a model state
123    pub fn view(&self, model: &App::Model) -> App::ViewModel {
124        self.app.view(model)
125    }
126}
127
128#[expect(deprecated)]
129impl<App> Default for AppTester<App>
130where
131    App: crate::App,
132    App::Capabilities: WithContext<App::Event, App::Effect>,
133{
134    fn default() -> Self {
135        let (command_sender, commands) = crate::capability::channel();
136        let (event_sender, events) = crate::capability::channel();
137        let (executor, spawner) = executor_and_spawner();
138        let capability_context = ProtoContext::new(command_sender, event_sender, spawner);
139        let command_spawner = CommandSpawner::new(capability_context.clone());
140
141        Self {
142            app: App::default(),
143            capabilities: App::Capabilities::new_with_context(capability_context),
144            context: Arc::new(AppContext {
145                commands,
146                events,
147                executor,
148            }),
149            command_spawner,
150        }
151    }
152}
153
154impl<App> AsRef<App::Capabilities> for AppTester<App>
155where
156    App: crate::App,
157{
158    fn as_ref(&self) -> &App::Capabilities {
159        &self.capabilities
160    }
161}
162
163impl<Ef, Ev> AppContext<Ef, Ev> {
164    pub fn updates(self: &Arc<Self>) -> Update<Ef, Ev> {
165        self.executor.run_all();
166        let effects = self.commands.drain().collect();
167        let events = self.events.drain().collect();
168
169        Update { effects, events }
170    }
171}
172
173/// Update test helper holds the result of running an app update using [`AppTester::update`]
174/// or resolving a request with [`AppTester::resolve`].
175#[derive(Debug)]
176#[must_use]
177pub struct Update<Ef, Ev> {
178    /// Effects requested from the update run
179    pub effects: Vec<Ef>,
180    /// Events dispatched from the update run
181    pub events: Vec<Ev>,
182}
183
184impl<Ef, Ev> Update<Ef, Ev> {
185    pub fn into_effects(self) -> impl Iterator<Item = Ef> {
186        self.effects.into_iter()
187    }
188
189    pub fn effects(&self) -> impl Iterator<Item = &Ef> {
190        self.effects.iter()
191    }
192
193    pub fn effects_mut(&mut self) -> impl Iterator<Item = &mut Ef> {
194        self.effects.iter_mut()
195    }
196
197    /// Assert that the update contains exactly one effect and zero events,
198    /// and return the effect
199    ///
200    /// # Panics
201    /// Panics if the update contains more than one effect or any events.
202    #[track_caller]
203    #[must_use]
204    pub fn expect_one_effect(mut self) -> Ef {
205        if self.events.is_empty() && self.effects.len() == 1 {
206            self.effects.pop().unwrap()
207        } else {
208            panic!(
209                "Expected one effect but found {} effect(s) and {} event(s)",
210                self.effects.len(),
211                self.events.len()
212            );
213        }
214    }
215
216    /// Assert that the update contains exactly one event and zero effects,
217    /// and return the event
218    ///
219    /// # Panics
220    /// Panics if the update contains more than one event or any effects.
221    #[track_caller]
222    #[must_use]
223    pub fn expect_one_event(mut self) -> Ev {
224        if self.effects.is_empty() && self.events.len() == 1 {
225            self.events.pop().unwrap()
226        } else {
227            panic!(
228                "Expected one event but found {} effect(s) and {} event(s)",
229                self.effects.len(),
230                self.events.len()
231            );
232        }
233    }
234
235    /// Assert that the update contains no effects or events
236    ///
237    /// # Panics
238    /// Panics if the update contains any effects or events.
239    #[track_caller]
240    pub fn assert_empty(self) {
241        if self.effects.is_empty() && self.events.is_empty() {
242            return;
243        }
244        panic!(
245            "Expected empty update but found {} effect(s) and {} event(s)",
246            self.effects.len(),
247            self.events.len()
248        );
249    }
250
251    /// Take effects matching the `predicate` out of the [`Update`]
252    /// and return them, mutating the `Update`
253    pub fn take_effects<P>(&mut self, predicate: P) -> VecDeque<Ef>
254    where
255        P: FnMut(&Ef) -> bool,
256    {
257        let (matching_effects, other_effects) = self.take_effects_partitioned_by(predicate);
258
259        self.effects = other_effects.into_iter().collect();
260
261        matching_effects
262    }
263
264    /// Take all of the effects out of the [`Update`]
265    /// and split them into those matching `predicate` and the rest
266    pub fn take_effects_partitioned_by<P>(&mut self, predicate: P) -> (VecDeque<Ef>, VecDeque<Ef>)
267    where
268        P: FnMut(&Ef) -> bool,
269    {
270        std::mem::take(&mut self.effects)
271            .into_iter()
272            .partition(predicate)
273    }
274}
275
276impl<Effect, Event> Command<Effect, Event>
277where
278    Effect: Send + 'static,
279    Event: Send + 'static,
280{
281    /// Assert that the Command contains _exactly_ one effect and zero events,
282    /// and return the effect
283    ///
284    /// # Panics
285    /// Panics if the command does not contain exactly one effect, or contains any events.
286    #[track_caller]
287    pub fn expect_one_effect(&mut self) -> Effect {
288        assert!(
289            self.events().next().is_none(),
290            "expected only one effect, but found an event"
291        );
292        let mut effects = self.effects();
293        match (effects.next(), effects.next()) {
294            (None, _) => panic!("expected one effect but got none"),
295            (Some(effect), None) => effect,
296            _ => panic!("expected one effect but got more than one"),
297        }
298    }
299}
300
301/// Panics if the pattern doesn't match an `Effect` from the specified `Update`
302///
303/// Like in a `match` expression, the pattern can be optionally followed by `if`
304/// and a guard expression that has access to names bound by the pattern.
305///
306/// # Example
307///
308/// ```
309/// # use crux_core::testing::Update;
310/// # enum Effect { Render(String) };
311/// # enum Event { None };
312/// # let effects = vec![Effect::Render("test".to_string())].into_iter().collect();
313/// # let mut update = Update { effects, events: vec!(Event::None) };
314/// use crux_core::assert_effect;
315/// assert_effect!(update, Effect::Render(_));
316/// ```
317#[macro_export]
318macro_rules! assert_effect {
319    ($expression:expr, $(|)? $( $pattern:pat_param )|+ $( if $guard: expr )? $(,)?) => {
320        assert!($expression.effects().any(|e| matches!(e, $( $pattern )|+ $( if $guard )?)));
321    };
322}