-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Introduce a crate to write unit tests of Dioxus apps #5323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
23a9be0
cb1c416
5f66041
0a82eff
75f52bb
68f601f
1166bf2
76c629b
61a6705
e7767e5
8b7683e
2b950f6
1a204da
dcbb8d3
5f797b8
1ab204e
ed386e9
b4d9798
a97fe28
54bc071
896806f
11b4d9d
b7b5d5f
03ed7a8
0d64fe2
09220bf
af1c629
9d29175
0b9f7eb
f0cd255
d38954d
2da3523
4c8c982
c737a9f
5527093
f698c43
cb887ca
1bc4838
3d721f0
741f601
f78f003
66f8c74
dd66555
bc72605
4a7627d
a2a152a
e414385
793903b
dc4cd4f
58e9ab3
7ab8351
58a1946
4f87b72
ce71740
30d1644
8debf08
fe65b54
d56dccb
7b02593
8691398
49d58f3
c110ffa
5e596a2
d37fc27
f7d1933
270b4ee
e9ece73
63dbfd0
745e0a2
95cc52f
d84801e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| [package] | ||
| name = "dioxus-test" | ||
| version = { workspace = true } | ||
| authors = ["Bradford Hovinen"] | ||
| edition = "2024" | ||
| description = "Test library for Dioxus based on native renderer" | ||
| license = "MIT OR Apache-2.0" | ||
| repository = "https://github.com/DioxusLabs/dioxus/" | ||
| homepage = "https://dioxuslabs.com/learn/0.7/getting_started" | ||
| keywords = ["dom", "ui", "gui", "react", "testing"] | ||
|
|
||
| [dependencies] | ||
| blitz-dom = { workspace = true } | ||
| dioxus-core = { workspace = true } | ||
| dioxus-html = { workspace = true } | ||
| dioxus-native-dom = { workspace = true } | ||
| taffy = { workspace = true } | ||
| tokio = { workspace = true } | ||
| tracing = { workspace = true } | ||
|
|
||
| [dev-dependencies] | ||
| dioxus = { workspace = true } | ||
| tokio = { version = "1.48", features = ["rt", "macros"] } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| use dioxus_core::{ElementId, Event}; | ||
| use dioxus_html::{ | ||
| Modifiers, PlatformEventData, | ||
| geometry::{Coordinates, euclid::Point2D}, | ||
| }; | ||
| use dioxus_native_dom::{DioxusDocument, synthetic_click_event}; | ||
| use std::rc::Rc; | ||
|
|
||
| /// A reference to DOM node managed by a [crate::Tester]. | ||
| /// | ||
| /// This provides facilities for interacting with the node, querying its layout properties, and | ||
| /// obtaining its content. | ||
| pub struct TestElement<'doc> { | ||
| pub(crate) document: &'doc DioxusDocument, | ||
| pub(crate) node: &'doc blitz_dom::Node, | ||
| } | ||
|
|
||
| impl<'vdom> TestElement<'vdom> { | ||
| /// Synonym of [TestElement::click]. | ||
| pub fn tap(&self) { | ||
| self.click() | ||
| } | ||
|
|
||
| /// Dispatches a `click` event on this element. | ||
| /// | ||
| /// The exact location of the click is unspecified. | ||
| /// | ||
| /// If the element has an `onclick` handler, it will be invoked once [crate::Tester::pump] is | ||
| /// called. | ||
| pub fn click(&self) { | ||
| self.send_event( | ||
| "click", | ||
| Event::new( | ||
| Rc::new(PlatformEventData::new(synthetic_click_event( | ||
| self.node, | ||
| Modifiers::empty(), | ||
| ))), | ||
| true, | ||
| ), | ||
| ) | ||
| } | ||
|
|
||
| /// Sends an event with the given `name` to this element. | ||
| /// | ||
| /// The event is registered with the Dioxus runtime. A subsequent call to [crate::Tester::pump] | ||
| /// causes the event handler to be invoked, if one is present. | ||
| /// | ||
| /// If no event handler is registered corresponding to the event `name`, then this method has no | ||
| /// effect. | ||
| /// | ||
| /// This operates directly on the element, so that is is guaranteed to receive the event. This | ||
| /// might not reflect how the element would respond in reality. For example, a click at the | ||
| /// coordinates of a button which is behind a frost element will not reach the button. But this | ||
| /// method behaves as though it would. | ||
| /// | ||
| /// The `event` parameter must contain a [PlatformEventData] with a payload corresponding to the | ||
| /// specific event type. This method panics if the event payload has the wrong type. | ||
| pub fn send_event(&self, name: &str, event: Event<PlatformEventData>) { | ||
| let propagates = event.propagates(); | ||
| self.document.vdom.runtime().handle_event( | ||
| name, | ||
| Event::new(event.data, propagates), | ||
| self.get_element_id() | ||
| .expect("Expected element to have a Dioxus ID"), | ||
| ); | ||
| } | ||
|
|
||
| /// Returns a `String` consisting of the HTML of this element and all of its children. | ||
| pub fn outer_html(&self) -> String { | ||
| self.node.outer_html() | ||
| } | ||
|
|
||
| /// Returns a `String` consisting of the HTML of this element's children, not including this | ||
| /// element itself. | ||
| pub fn inner_html(&self) -> String { | ||
| let inner_html_parts: Vec<_> = self | ||
| .node | ||
| .children | ||
| .iter() | ||
| .filter_map(|child_id| { | ||
| self.document | ||
| .get_node(*child_id) | ||
| .map(|child| child.outer_html()) | ||
| }) | ||
| .collect(); | ||
| inner_html_parts.join("") | ||
| } | ||
|
|
||
| /// Returns the calculated [Coordinates] of the centre of this element. | ||
| pub fn center(&self) -> Coordinates { | ||
| let upper_left = self.upper_left(); | ||
| let lower_right = self.lower_right(); | ||
| Coordinates::new( | ||
| upper_left.screen().lerp(lower_right.screen(), 0.5), | ||
| upper_left.client().lerp(lower_right.client(), 0.5), | ||
| upper_left.element().lerp(lower_right.element(), 0.5), | ||
| upper_left.page().lerp(lower_right.page(), 0.5), | ||
| ) | ||
| } | ||
|
|
||
| /// Returns the calculated [Coordinates] of the upper-left corner of this element. | ||
| pub fn upper_left(&self) -> Coordinates { | ||
| let upper_left = self.node.final_layout.location; | ||
| Coordinates::new( | ||
| Self::to_point2d(upper_left), | ||
| Self::to_point2d(upper_left), | ||
| Self::to_point2d(upper_left), | ||
| Self::to_point2d(upper_left), | ||
| ) | ||
| } | ||
|
|
||
| /// Returns the calculated [Coordinates] of the upper-right corner of this element. | ||
| pub fn upper_right(&self) -> Coordinates { | ||
| let mut upper_right = self.node.final_layout.location; | ||
| upper_right.x += self.node.final_layout.content_box_width(); | ||
| Coordinates::new( | ||
| Self::to_point2d(upper_right), | ||
| Self::to_point2d(upper_right), | ||
| Self::to_point2d(upper_right), | ||
| Self::to_point2d(upper_right), | ||
| ) | ||
| } | ||
|
|
||
| /// Returns the calculated [Coordinates] of the lower-left corner of this element. | ||
| pub fn lower_left(&self) -> Coordinates { | ||
| let mut lower_left = self.node.final_layout.location; | ||
| lower_left.y += self.node.final_layout.content_box_height(); | ||
| Coordinates::new( | ||
| Self::to_point2d(lower_left), | ||
| Self::to_point2d(lower_left), | ||
| Self::to_point2d(lower_left), | ||
| Self::to_point2d(lower_left), | ||
| ) | ||
| } | ||
|
|
||
| /// Returns the calculated [Coordinates] of the lower-right corner of this element. | ||
| pub fn lower_right(&self) -> Coordinates { | ||
| let mut lower_right = self.node.final_layout.location; | ||
| lower_right.x += self.node.final_layout.content_box_width(); | ||
| lower_right.y += self.node.final_layout.content_box_height(); | ||
| Coordinates::new( | ||
| Self::to_point2d(lower_right), | ||
| Self::to_point2d(lower_right), | ||
| Self::to_point2d(lower_right), | ||
| Self::to_point2d(lower_right), | ||
| ) | ||
| } | ||
|
|
||
| fn to_point2d<Space>(point: taffy::geometry::Point<f32>) -> Point2D<f64, Space> { | ||
| Point2D::new(point.x as f64, point.y as f64) | ||
| } | ||
|
|
||
| /// Returns the calculated size of this element as a tuple (width, height) in screen pixels. | ||
| pub fn size(&self) -> (f32, f32) { | ||
| let height = self.node.final_layout.content_box_height(); | ||
| let width = self.node.final_layout.content_box_width(); | ||
| (width, height) | ||
| } | ||
|
|
||
| fn get_element_id(&self) -> Option<ElementId> { | ||
| self.node | ||
| .element_data()? | ||
| .attrs | ||
| .iter() | ||
| .find(|attr| *attr.name.local == *"data-dioxus-id") | ||
| .and_then(|attr| attr.value.parse::<usize>().ok()) | ||
| .map(ElementId) | ||
| } | ||
| } | ||
|
|
||
| impl<'doc> std::fmt::Debug for TestElement<'doc> { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| f.debug_struct("TestElement") | ||
| .field("node", &self.node) | ||
| .finish() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| #![cfg_attr(docsrs, feature(doc_cfg))] | ||
| #![allow(clippy::test_attr_in_doctest)] // The doctests need to show examples of tests | ||
| //! A testing crate for Dioxus. | ||
| //! | ||
| //! This crate facilitates rendering, interacting with, and querying the DOM in tests of Dioxus | ||
| //! apps. Tests have fairlz precise control over both the rendering lifecycle and asynchronous | ||
| //! operations. Thus they can assert both on the final outcome of interactions, such as the | ||
| //! rendered data obtained from a call to a backend, as well as intermediate states, such as the | ||
| //! presence of a spinner while loading data. | ||
| //! | ||
| //! This uses the dioxus-native crate to manage the DOM, which in turn uses | ||
| //! [Blitz](https://crates.io/crates/blitz) for layout. It does not depend on a browser or any | ||
| //! other external process. | ||
| //! | ||
| //! Tests operate "headless", so they cannot render their state to the screen. | ||
| //! | ||
| //! ## Usage | ||
| //! | ||
| //! Tests can construct a [Tester] instance to render and interact with the DOM. To construct a | ||
| //! [Tester], the test can invoke the [render] function on a Dioxus component. They must invoke the | ||
| //! `build` to trigger the initial layout. The tester provides methods for querying elements by | ||
| //! CSS selector or by test ID. | ||
| //! | ||
| //! ``` | ||
| //! use dioxus::prelude::*; | ||
| //! use dioxus_test::render; | ||
| //! | ||
| //! #[component] | ||
| //! fn MyComponent() -> Element { | ||
| //! rsx! { | ||
| //! div { | ||
| //! class: "test-component", | ||
| //! "Hello, world!" | ||
| //! } | ||
| //! } | ||
| //! } | ||
| //! | ||
| //! #[test] | ||
| //! fn my_component_renders_correctly() { | ||
| //! let tester = render(MyComponent).build(); | ||
| //! assert_eq!( | ||
| //! tester.find_by_css_selector(".test-component").unwrap().inner_html(), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I'm fine with panicking if that's what you prefer, though. |
||
| //! "Hello, world!" | ||
| //! ); | ||
| //! } | ||
| //! ``` | ||
| //! | ||
| //! [Tester] also provides methods for interacting with elements and driving the runtime. After | ||
| //! interacting with an element, the test must call [Tester::pump] to cause the event handler to be | ||
| //! invoked. | ||
| //! | ||
| //! ``` | ||
| //! use dioxus::prelude::*; | ||
| //! use dioxus_test::render; | ||
| //! | ||
| //! #[component] | ||
| //! fn MyComponent() -> Element { | ||
| //! let mut text = use_signal(|| "Click me!"); | ||
| //! rsx! { | ||
| //! button { | ||
| //! class: "test-button", | ||
| //! onclick: move |_| { | ||
| //! *text.write() = "Don't click any more!"; | ||
| //! }, | ||
| //! {text} | ||
| //! } | ||
| //! } | ||
| //! } | ||
| //! | ||
| //! #[tokio::test] | ||
| //! async fn my_component_changes_button_text_on_click() { | ||
| //! let mut tester = render(MyComponent).build(); | ||
| //! 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(), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to have
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| //! "Don't click any more!" | ||
| //! ); | ||
| //! } | ||
| //! ``` | ||
| //! | ||
| //! ## Asynchronous operations | ||
| //! | ||
| //! The method [Tester::pump] returns control to the async runtime and thus drives any asynchronous | ||
| //! operations such as requests to the backend. If any rendering depends on the result of a request, | ||
| //! then the test must invoke [Tester::pump] to resolve that. | ||
| //! | ||
| //! The test can also assert on the state of the DOM while backend requests are in flight. | ||
| //! | ||
| //! ## 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's a frost?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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/. |
||
| //! it were reachable even though it would not be in reality. | ||
| //! | ||
| //! The layout system is limited by what the Blitz layout system can support. Since Blitz is not | ||
| //! complete as of the time of writing, computed layouts will often not be as in reality. | ||
|
hovinen marked this conversation as resolved.
Outdated
|
||
|
|
||
| mod element; | ||
| mod tester; | ||
|
|
||
| pub use element::TestElement; | ||
| pub use tester::{Tester, TesterError, render}; | ||
Uh oh!
There was an error while loading. Please reload this page.