Introduce a crate to write unit tests of Dioxus apps#5323
Introduce a crate to write unit tests of Dioxus apps#5323hovinen wants to merge 9 commits intoDioxusLabs:mainfrom
Conversation
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.
packages/test/src/tester.rs
Outdated
| timeout(PUMP_TIMEOUT, self.document.vdom.wait_for_work()).await?; | ||
| while self.document.poll(None) {} | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thanks -- let me try this and see what I can come up with.
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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
clickevent, 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
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, theUiEventenum from Blitz does not support a "click" event and I did not want to create multiple PRs in different repositories for now.Fixes #5324