Skip to content

Fluent Command Assertions#523

Merged
StuartHarris merged 6 commits intomasterfrom
michael/testing-helpers
May 5, 2026
Merged

Fluent Command Assertions#523
StuartHarris merged 6 commits intomasterfrom
michael/testing-helpers

Conversation

@mhedgpeth
Copy link
Copy Markdown
Collaborator

The current way to assert effects and resolve them to events is verbose, and that leaves me not testing my app as much as I would like.

// today, asserting one HTTP request, resolving it, then feeding the event back
let mut request = cmd.expect_one_effect().expect_http();
assert_eq!(request.operation, HttpRequest::get("...").build());
request.resolve(HttpResult::Ok(...)).unwrap();
let event = cmd.expect_one_event();
let cmd = local.update(event, &api_key());

This change introduces a fluent way to do this, which would make testing more
accessible while writing it, leading to better tests.

There are three variants of this:

Quick render assertion: single effect, no payload check:

// was:
let mut cmd = app.update(Event::Reset, &mut model);
cmd.expect_one_effect().expect_render();

// now:
app.update(Event::Reset, &mut model).expect_only_render();

Multiple chained assertions: assert each effect's variant and operation, in order:

// was:
let secret_request = cmd.expect_effect().expect_secret();
assert_eq!(secret_request.operation, SecretRequest::Fetch(API_KEY_NAME.to_string()));
let kv_request = cmd.expect_one_effect().expect_key_value();
assert!(matches!(kv_request.operation, KeyValueOperation::Get { .. }));

// now:
cmd.expect_secret_with(|op| {
       assert_eq!(op, &SecretRequest::Fetch(API_KEY_NAME.to_string()));
   })
   .expect_key_value_with(|op| {
       assert!(matches!(op, KeyValueOperation::Get { .. }));
   });

Resolve to an event: assert, resolve, and hand back the event for update():

// was:
let mut request = cmd.expect_one_effect().expect_http();
request.resolve(HttpResult::Ok(HttpResponse::ok().body(weather_json).build())).unwrap();
let event = cmd.expect_one_event();
let cmd = local.update(event, &api_key());

// now:
let event = cmd
    .resolve_http(|op| {
        assert_eq!(op, &expected_request);
        HttpResult::Ok(HttpResponse::ok().body(weather_json).build())
    })
    .expect_event();
let cmd = local.update(event, &api_key());

The closure does double duty: assert on &op, then return the response payload the validator hands to request.resolve(...) for you. The next chain step (or .expect_event()) sees whatever the resolved task emits.

Since the current API still allows for edge-case testing and since I don't want to introduce breaking changes, it's untouched.

The examples are updated with the fluent style, as well as the book. New panic-case tests in crux_core/tests/effect_test_ext.rs cover empty queues, wrong variants, missing events, and expect_only_* strictness.

@mhedgpeth mhedgpeth requested review from StuartHarris and charypar May 2, 2026 15:51
@mhedgpeth
Copy link
Copy Markdown
Collaborator Author

@StuartHarris @charypar I'm open to changing the ergonomics of this, I just made a judgement call on what looked best to me here, happy to rename anything if you find it confusing.

This has been something I've been wanting that I've built internally but just makes life easier while writing tests.

@mhedgpeth mhedgpeth force-pushed the michael/testing-helpers branch from 292fe3d to 02e9577 Compare May 2, 2026 15:56
Copy link
Copy Markdown
Collaborator

@charypar charypar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super nice! 👏🏻 I left some minor suggestions, but it reads really nicely and makes the tests more readable still. Great! ❤️

Comment on lines +267 to +296
let test_ext = quote! {
pub trait #test_ext_trait_ident<Event>
where
Event: ::core::marker::Send + 'static,
{
#(#test_ext_trait_methods)*

fn then_event<F>(&mut self, f: F) -> &mut Self
where
F: ::core::ops::FnOnce(&Event);
}

impl<Event> #test_ext_trait_ident<Event> for ::crux_core::Command<#enum_ident, Event>
where
Event: ::core::marker::Send + 'static,
{
#(#test_ext_impl_methods)*

#[track_caller]
fn then_event<F>(&mut self, f: F) -> &mut Self
where
F: ::core::ops::FnOnce(&Event),
{
let ev = self.events().next()
.unwrap_or_else(|| panic!("expected an event but got none"));
f(&ev);
self
}
}
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make sure we only compile these in #[cfg(test)]? Should be as easy as annotating the trait definition and implementation as such.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@charypar that would make them only available in in the same crate that declared Effect and would shut out integration tests or other crates. The current assertions don't have cfg(test), I believe, for this reason.

So I think we leave it as is; does that work for you?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that right? I'd have thought that the test binaries built from tests/ modules would automatically build their parent crate (which declares the Effect) in cfg(test) as well 🤔

I'd really like us to avoid generating test-only helpers and code in people's production binaries, for both compile times and wasm bundle sizes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gated as many test helpers behind #[cfg(test)] as I can. The others I've left. e.g. Effect::is_*() and Effect::into_*() are more general purpose and the others are used cross-crate currently (although I'll see if we can fix that too)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I'm going to put these in a stacked PR

Comment thread docs/src/part-1/testing.md Outdated
Comment thread docs/src/part-2/testing-effects.md Outdated
@StuartHarris
Copy link
Copy Markdown
Member

I love it, thanks @mhedgpeth 🚀

Copy link
Copy Markdown
Member

@StuartHarris StuartHarris left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic!

Comment thread docs/src/part-1/testing.md Outdated
mhedgpeth and others added 3 commits May 3, 2026 08:19
Co-authored-by: Viktor Charypar <charypar@gmail.com>
Co-authored-by: Viktor Charypar <charypar@gmail.com>
@StuartHarris StuartHarris force-pushed the michael/testing-helpers branch from 1cd1187 to 3471d48 Compare May 3, 2026 19:49
* gate as many test helpers behind cfg(test) as we can

* cross crate calls to testing helpers now need feature=testing in dev-deps

* replace cfg(test) with doc(hidden)
@mhedgpeth
Copy link
Copy Markdown
Collaborator Author

@StuartHarris I'm going to make sure the new declaration in dev-dependencies is included in the book real quick before we decide to merge.

@mhedgpeth mhedgpeth requested a review from StuartHarris May 4, 2026 12:06
@mhedgpeth
Copy link
Copy Markdown
Collaborator Author

@StuartHarris I made three changes after merging from your change:

  1. The EffectTestExt wasn't gated by the testing feature, so I added that
  2. Updated the book to include these details in the introduction to testing
  3. Slightly improved the effect testing language to "expecting A, got B" so they're easier to troubleshoot.

I think these are ready, let me know and we can merge. It would be great to include this in 0.18.

Copy link
Copy Markdown
Member

@StuartHarris StuartHarris left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great. Thank you. It's a shame the tokens are now emitted without formatting, but I don't suppose that matters. The extra compile time to pretty print them is probably not warranted seeing as few if anyone needs to see the expanded content here. I'll merge and look at getting 0.18 out

@StuartHarris StuartHarris merged commit b0e5ba6 into master May 5, 2026
29 checks passed
@StuartHarris StuartHarris deleted the michael/testing-helpers branch May 5, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants