Skip to content

Introduce a crate to write unit tests of Dioxus apps#5323

Open
hovinen wants to merge 69 commits into
DioxusLabs:mainfrom
hovinen:introduce-testing-library
Open

Introduce a crate to write unit tests of Dioxus apps#5323
hovinen wants to merge 69 commits into
DioxusLabs:mainfrom
hovinen:introduce-testing-library

Conversation

@hovinen

@hovinen hovinen commented Feb 20, 2026

Copy link
Copy Markdown

This is a preliminary version of a library in the spirit of React or Flutter to allow easy unit testing of Dioxus apps.

Right now it only supports the click event, but I believe that is sufficient to show the design. Once the overall design is finalised, it can be fully fleshed out.

I placed this directly in the Dioxus repository. I'm also fine with keeping it as a separate crate if desired. However, this requires a few small changes to the dioxus-native crate to allow the creation of synthetic events. Otherwise the existing visibility restrictions make it impossible to dispatch DOM events from the test.

I decided to add fairly fleshed out documentation including doctests so as to illustrate the use of the library. I have tested it against a personal project and it is working quite well.

Alternatives considered

  • Writing a new renderer. I had considered this but found that it would be pretty much a duplicate of the existing dioxus-native.
  • Dispatching events via Document::handle_ui_event. This would mean that the events would act more like real user-generated events, where, say, a click happens at particular coordinates and the framework identifies the element which is hit. This would be more "realistic" in the sense that a click on a button which is covered by another element would not hit the button but rather the element covering it. However, the UiEvent enum from Blitz does not support a "click" event and I did not want to create multiple PRs in different repositories for now.

Fixes #5324

To be turned into prose:

- Existing testing options are dioxus-ssr and Playwright
  - dioxus-ssr does not allow interaction with the DOM in a test. As soon
    as the test needs to click a button, it won't work.
  - Playwright is extremely heavy-weight. Most importantly, it creates a
    technological divide between the test code and the component under
    test. This makes it really hard to instrument the component under
    test from the test itself. Writing new tests is a major undertaking.
- I want it to be easy to write new tests. And the tests should execute
  very quickly in the normal case.

- Inspired by testing libraries in Flutter and React
- Based on dioxus-native and blitz
- Test creates a `Tester` by rendering into it. It can then query
  elements to obtain `TestElement` instances which reference the DOM.
  They can dispatch events via methods on those functions.
- For async and handling events: call `Tester::pump`. This awaits so that
  it passes control back to the runtime to handle any other async tasks.
- For interaction: events dispatched through the VirtualDom
- This requires a change to dioxus-native: we need to be able to
  construct synthetic events. Previously, visibility restrictions
  prevented that. This adds a function to generate a synthetic click
  analogous to the function in Blitz.

- In this PR only "click" is supported. Further events can be added but I
  would like feedback before fleshing it out.
- Since events go through the VirtualDom, they hit the target element
  directly. If the target element is covered by a frost or is otherwise
  inaccessible, then the click will have no effect in reality but will
  still work in the test.
- The same might be true of disabled elements.

- Writing a new renderer
  - Seems redundant since the renderer in dioxus-native serves the needs
    of this library perfectly.
- Dispatching events via `DioxusDocument::handle_ui_event`:
  - Layout with Blitz does not work reliably enough
  - There is no "click" event variant in the `UiEvent` enum. So this
    would require a change in Blitz as well.
@hovinen hovinen requested a review from a team as a code owner February 20, 2026 08:29
timeout(PUMP_TIMEOUT, self.document.vdom.wait_for_work()).await?;
while self.document.poll(None) {}
Ok(())
}

@ealmloff ealmloff Mar 3, 2026

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.

Very excited about this, thanks for working on it!

Instead of requiring explicit event pumping, we could mimic the Playwright test API and make queries asynchronously run the event loop:

let mut tester = dioxus_test::render(AComponent).build();
// Wait for make-request-button to load and click it to make a network request.
tester.find_by_test_id("make-request-button").click().await?;
// Assert on the state of the UI while the network request is in flight.
expect(tester.find_by_test_id("loading-indicator")).to_be_visible().await?;
// Receive the server response and assert on the state of the UI after the response
// is received and the UI has been rerendered.
expect(tester.find_by_test_id("resolved-content")).to_be_visible().await?;

In some cases, waiting for work and applying a single tick of DOM operations could provide more control over testing, but I think the familiarity of the Playwright-style API and intuitive behavior in the presence of feedback loops with DOM events are typically more important.

For example, onmounted and use_effect will only trigger after the document has applied edits. With the current model, I think that would require rendering, then waiting for a 0ms timeout, then asserting the state has changed based on the onmounted event. If asserting on that state also pumped the DOM, you could just assert the state is correct directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks -- let me try this and see what I can come up with.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hi Evan, thanks for your patience on this. It took me a while to find a workable structure for this, and I admittedly didn't have as much to work on it as I would have liked.

I settled on a second layer on top of the initial API. So one can use the pump method to drive the event loop, but there's a higher level API which lets the caller await conditions on the DOM and assert on those.

Since the tester doesn't know when all async operations have fully settled, it has to rely on heuristics to some degree. It tries up to a maximum number of attempts to obtain an element or to make an assertion and gives up eventually. The pump method is similarly equipped with a timeout since I don't have an easy way to detect whether it would just wait forever.

I had to introduce some new concepts to support assertions in this model. A given element on which the test is to assert might already be present but have the wrong state at the time the assertion is invoked. So there needs to be a concept of "assert that this is eventually true, even if it's not true right now." To support this, I introduced matchers similar to the GoogleTest crate. These can be passed into the tester which can just repeatedly try the assertion until it's true, giving up after a maximum number of tries.

I've tried this out on a private project and the API seems reasonable.

There are some more TODOs on this: I need to update the overall docs and I'd like to make some improvements to the API. But this should at least convey the idea.

Please let me know what you think. Thanks!

@ealmloff ealmloff Mar 23, 2026

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.

Much nicer! We could make this more composable by extracting a poll once or await helper so you can do something like:

tester.get("selector").immediately()?;
tester.get("selector").await?;
tester.get("selector").expect(inner_html(to_contain("hello"))).await?;
tester.get("selector").expect(inner_html(to_contain("hello"))).immediately()?;

It isn't fully built out yet, but I have a prototype in this commit: 358b9ed

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Interesting! I had played around trying to build a fluent interface like that but found that whatever the query method on DocumentTester returned had to contain an exclusive reference to DocumentTester, which caused no end of problems. I admittedly didn't consider implementing IntoFuture directly.

I'll play around with your prototype and see how well it works in the project I am doing.

@hovinen hovinen Apr 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Hi Evan, I've built on your commit and managed to get it to "feature-parity" with the previous solution. I've pushed what I have, but it's going to need a lot more cleanup before it's ready for merge. Still, I felt this is a good checkpoint to show you what I have.

One can construct a query, then await on it or invoke immediately() to assert that it already resolves to an element. Similarly, one can call expect() and supply a Matcher on that query. The expectation then follows the same pattern: one can .await or invoke .immediately().

Here's how its use looks:

let mut tester = create_tester(...);

tester.query(by_testid("some-button")).click().await?;

tester
    .query(by_testid("some-state"))
    .expect(inner_html(not(contains_string("Button not yet clicked"))))
    .await?;
Ok(tester
    .query(by_testid("some-state"))
    .expect(inner_html(contains_string("Button now clicked")))
    .immediately()?)

(I'll update the docs again once the API is reasonably settled.)

This was quite tricky to get right due to all the lifetimes in play here. I settled on having the asynchronous loop part just return node IDs so that they don't hold any references, then resolving them afterwards.

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.

Yes, that looks much nicer!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

So, after what I'm sure was the most challenging lifetime jujitsu I have ever experienced, I'm happy to say that I got everything working with the fluent API. I've cleaned things up a bit and updated this PR with my current state. I still have quite a few things to do before this is ready, especially updating docs and seeing to decent error messages when assertions fail.

One caveat: I'm not sure the compiler error messages will be especially helpful in the case that one makes a mistake, such as by using a matcher in expect which doesn't make sense in that context. There might be need to iterate on that design a little.

I'm also thinking that implementing a full set of matchers would essentially duplicate a lot of the GoogleTest crate. So I'm musing over whether it makes sense just to introduce a dependency on that crate. I'd be happy to hear your thoughts on that.

I'll continue updating this branch in the coming days, as I find the time.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

All right, I think I've settled on a reasonable solution which is ready for another round of review. Thanks!

@ealmloff ealmloff added enhancement New feature or request native Related to dioxus-native labels Mar 3, 2026
@hovinen hovinen requested a review from ealmloff March 23, 2026 12:19

@nicoburns nicoburns left a comment

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.

This overall looks really good to me. I've made some suggestions, some of which are a lot more speculative than others.

Comment thread Cargo.toml Outdated
Comment thread packages/test/src/document.rs Outdated
Comment thread packages/native-dom/src/events.rs
Comment thread packages/test/src/document.rs Outdated
Comment on lines +392 to +396
Ok(node_ids
.into_iter()
.map(|node_id| self.data.node_id_to_element(node_id))
.collect())
})

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.

Might it be possible to add a map_result method to Waitable, then make the IntoFuture impl generic for all Waitable? I think .immediately() could then also be a provided method of the Waitable trait.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the suggestion! I've tried something like this but ran into lots of obscure lifetime issues. But I'll see what I can do.

@hovinen hovinen Apr 19, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

After a lot of revisions, I landed somewhere slightly different:

  • AllElementsCondition doesn't implement Waitable any more. Unlike ElementCondition, there is no clear unambiguous condition for which to wait. The query could, after all, legitimately yield no elements.
  • The 'immedately()methods are now just ordinary methods on each of the structs. There is no trait for them. This is because there is nothing which needs to be generic over the type on which to callimmediately()`.
  • The implementation of Matchable for AllElementsCondition now just references immediately(), eliminating the code duplication.
  • ElementCondition has a method which basically does what map_result would do. But since it's the only thing which needs it, it's not on any trait.
  • There are now only two IntoFuture implementations rather than three. It's still very slight duplication, but I think this makes the design a bit easier.

Comment thread packages/test/src/document.rs Outdated
}

pub struct ElementCondition<'vdom> {
data: &'vdom mut DocumentTester,

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.

Would it make things simpler if the "conditions" didn't own the DocumentTester but just had it passed into their methods? Probably then pump wouldn't be needed on the "conditions" and could be handled by the thing polling them?

@hovinen hovinen Apr 10, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not sure how you mean that.

Suppose I have something like:

tester.query(by_testid("something")).expect(inner_html(contains("Some text"))).await

ElementCondition is the output of query(). It needs the DocumentTester any time it wants to drive the DOM, e.g., in the IntoFuture implementation behind await. If it doesn't hold a reference to DocumentTester, then there's no other opportunity in the line above to deliver that reference to the IntoFuture implementation. Or am I missing something?

Comment thread packages/test/src/element.rs Outdated
Comment thread packages/test/src/lib.rs Outdated
//! fn my_component_renders_correctly() {
//! let tester = render(MyComponent).build();
//! assert_eq!(
//! tester.find_by_css_selector(".test-component").unwrap().inner_html(),

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.

Should find_by_css_selector just panic if it fails? .unwrap() here seems like noise.
(we could also always have a non-panicking try_find_by_css_selector in case someone does need that).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm following the philosophy in GoogleTest: don't panic, but rather return a Result and let the caller decide how to handle that.

I'm fine with panicking if that's what you prefer, though.

Comment thread packages/test/src/lib.rs
//! ## Limitations
//!
//! Interactions with the DOM operate directly on elements, not on the screen. So if, say, the test
//! dispatches a click on an element which is covered by a frost, the element will respond as though

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.

What's a frost?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm referring to a div which covers the page and prevents interaction with elements covered by it, similar to https://css-tricks.com/frosting-glass-css-filters/.

Comment thread packages/test/src/lib.rs Outdated
Comment thread packages/test/src/lib.rs Outdated
//! tester.find_first_by_css_selector(".test-button").unwrap().click();
//! tester.pump().await;
//! assert_eq!(
//! tester.find_first_by_css_selector(".test-button").unwrap().inner_html(),

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.

Would it make sense to have find_* method implicitly pump? (do they not already?). If, so we could remove the explicit pump above.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

These docs are outdated -- there is now just such an API. I just haven't updated the docs yet.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've updated the docs now to reflect the current API. Please take a look.

@hovinen

hovinen commented Apr 10, 2026

Copy link
Copy Markdown
Author

Thanks for your review! I'll address the code changes as soon as I get a chance.

@ealmloff

Copy link
Copy Markdown
Member

I started porting some of the component library tests to this crate in this branch to try out the api https://github.com/DioxusLabs/dioxus-components/tree/testing-lib (using this branch with a driver trait, more events and selectors https://github.com/ealmloff/dioxus/tree/testing-tweaks)

Overall, its nice to have everything in rust and much faster, but runs into a few issues:

  • Playwright lets you create selectors separately from assertions which makes tests less verbose. Currently, assertions in this pr own the selector which means you have to duplicate it. It would be nice if you could write:
let mut tester = dioxus_test::render(AComponent).build();
let click_count = tester.query("#click-count");
click_count.expect(inner_html(contains_string("Click count: 0"))).await?;
tester.query("button").click().await?;
click_count.expect(inner_html(contains_string("Click count: 1"))).await?;

instead of:

let mut tester = dioxus_test::render(AComponent).build();
tester.query("#click-count").expect(inner_html(contains_string("Click count: 0"))).await?;
tester.query("button").click().await?;
tester.query("#click-count").expect(inner_html(contains_string("Click count: 1"))).await?;
  • Blitz crashes if you run too many tests in parallel Resolving HtmlDocument in many different threads panics blitz#430
  • Missing some common selectors to assert things about attribute values, focus status, etc
  • Missing inputs like keydown, and hover
  • Only works in the blitz renderer. While blitz can test the majority of functionality for the component library, some tests with js for things like focus traps only work in full browser. With Get wasm bindgen working on desktop #5194, hopefully we can pull out a driver trait and implement one backing for renderers that implement js

@hovinen

hovinen commented May 25, 2026

Copy link
Copy Markdown
Author

Thanks for the feedback!

I respond to your points inline below.

I started porting some of the component library tests to this crate in this branch to try out the api https://github.com/DioxusLabs/dioxus-components/tree/testing-lib (using this branch with a driver trait, more events and selectors https://github.com/ealmloff/dioxus/tree/testing-tweaks)

Overall, its nice to have everything in rust and much faster, but runs into a few issues:

  • Playwright lets you create selectors separately from assertions which makes tests less verbose. Currently, assertions in this pr own the selector which means you have to duplicate it. It would be nice if you could write:
let mut tester = dioxus_test::render(AComponent).build();
let click_count = tester.query("#click-count");
click_count.expect(inner_html(contains_string("Click count: 0"))).await?;
tester.query("button").click().await?;
click_count.expect(inner_html(contains_string("Click count: 1"))).await?;

instead of:

let mut tester = dioxus_test::render(AComponent).build();
tester.query("#click-count").expect(inner_html(contains_string("Click count: 0"))).await?;
tester.query("button").click().await?;
tester.query("#click-count").expect(inner_html(contains_string("Click count: 1"))).await?;

This would be nice but will require some more thought. Right now the types returned by query and query_all hold an exclusive reference to the DocumentTester. This is needed so that they can drive the event loop when one await's on them. I'll see what I can do.

I intend to add these, but wanted to get the basic design right first. Would you consider these to be blockers for merging this PR, or can they be added after it is merged?

  • Only works in the blitz renderer. While blitz can test the majority of functionality for the component library, some tests with js for things like focus traps only work in full browser. With Get wasm bindgen working on desktop #5194, hopefully we can pull out a driver trait and implement one backing for renderers that implement js

This would also be nice but I couldn't find an easy way to make it work without the Blitz renderer when I built it. I'm interested to see any changes which make such a generalisation possible.

@ealmloff

Copy link
Copy Markdown
Member

I intend to add these, but wanted to get the basic design right first. Would you consider these to be blockers for merging this PR, or can they be added after it is merged?

Events and more attribute values, no. Crashing when there are more than a few tests, yes

This would also be nice but I couldn't find an easy way to make it work without the Blitz renderer when I built it. I'm interested to see any changes which make such a generalisation possible.

I'm working on a couple of bugs with the wasm-bindgen bindings today. Might be able to get a prototype of this later this week

@hovinen

hovinen commented Jun 1, 2026

Copy link
Copy Markdown
Author

Sorry for the slow reply. I've had a couple of things going on recently.

I intend to add these, but wanted to get the basic design right first. Would you consider these to be blockers for merging this PR, or can they be added after it is merged?

Events and more attribute values, no. Crashing when there are more than a few tests, yes

I've applied the workaround Nico mentioned in DioxusLabs/blitz#430 (comment). Please let me know whether that helps with the crashes you have seen.

This would also be nice but I couldn't find an easy way to make it work without the Blitz renderer when I built it. I'm interested to see any changes which make such a generalisation possible.

I'm working on a couple of bugs with the wasm-bindgen bindings today. Might be able to get a prototype of this later this week

Looking forward!

This means these two structs can be reused. This improves ergnomics.

This requires putting the `DioxusDocument` behind an `Rc<RefCell<..>>`
and using interior mutability. It introduces a potential risk of panics
if the test tries doing multiple things with the tester concurrently.
@hovinen

hovinen commented Jun 1, 2026

Copy link
Copy Markdown
Author
* Playwright lets you create selectors separately from assertions which makes tests less verbose. Currently, assertions in this pr own the selector which means you have to duplicate it. It would be nice if you could write:
let mut tester = dioxus_test::render(AComponent).build();
let click_count = tester.query("#click-count");
click_count.expect(inner_html(contains_string("Click count: 0"))).await?;
tester.query("button").click().await?;
click_count.expect(inner_html(contains_string("Click count: 1"))).await?;

instead of:

let mut tester = dioxus_test::render(AComponent).build();
tester.query("#click-count").expect(inner_html(contains_string("Click count: 0"))).await?;
tester.query("button").click().await?;
tester.query("#click-count").expect(inner_html(contains_string("Click count: 1"))).await?;

I've made some adjustments to enable this: Relevant methods on ElementCondition and AllElementsCondition now take a shared reference rather then consuming the struct.

The downside is that this means having to use interior mutability on the DioxusDocument. So there is a risk of panics if the tester does odd things like trying to concurrently pump the event loop and interact with the DOM. But they just shouldn't do that 🙂.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request native Related to dioxus-native

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dioxus testing library

3 participants