frontest/
lib.rs

1//! A lightweight library to query and assert DOM.
2//!
3//! Frontest is heavily inspired by [`dom-testing-library`] and [`react-testing-library`].
4//! It provides a set of queries that you can use to quickly find your elements in document
5//! with respect to accessibility priorities.
6//!
7//! # Basic usage:
8//!
9//! A [`Query`] trait allows for selecting elements in a way that users would interact with them
10//! with the respect for assisstive technology.
11//!
12//! ```no_run
13//! use frontest::prelude::*;
14//! use gloo::utils::{body, document};
15//!
16//! let div = document().create_element("div").unwrap();
17//! div.set_inner_html(
18//!     r#"<div>
19//!         <label>
20//!             I will start testing my frontend!
21//!             <button>
22//!                 Take the red pill
23//!             </button>
24//!         </label>
25//!         <label>
26//!             It's too problematic dude...
27//!             <button>
28//!                 Take the blue pill
29//!             </button>
30//!         </label>
31//!     </div>"#,
32//! );
33//! body().append_child(&div).unwrap();
34//!
35//! let go_to_matrix = div
36//!     .get(&HasRole("button").and(Not(HasLabel("It's too problematic dude..."))))
37//!     .unwrap();
38//! go_to_matrix.click();
39//!
40//! body().remove_child(&div).unwrap();
41//! ```
42//!
43//! # About testing:
44//!
45//! This library aims to allow developers to test their application in a way that a user would interact with it.
46//! For this purpose it is recommended to prioritize certain queries above another.
47//! Currently only two matchers are implemented. More will be available in future releases.
48//! Matchers should be prioritized like so:
49//! - [`HasRole`] Should always be used where possible. It allows accessing elements that are exposed into accessibility tree.
50//! - [`HasLabel`] Also should be used where possible. Is supported by screen readers and allows for easier focusing elements.
51//! - [`HasPlaceholder`] Not as great option as predecessors, however still a better alternative than [`HasText`] for accessible elements.
52//! - [`HasText`] Matches the text in a way that it is presented to the user.
53//!   All css rules applies eg. elements with `visibility: hidden;` won't be ever matched.
54//!   Can be used to select non-interactive components or further restrict other queries.
55//!
56//! # Matchers:
57//!
58//! Matchers are predicates for [`HtmlElement`]. They return [`true`] if given element suffices some criteria
59//! or [`false`] otherwise.
60//!
61//! Using the matcher [`Not`] and methods from [`Joinable`] trait it is possible to combine multiple matchers into
62//! a logical expression.
63//!
64//! ## You can easily implement your own `Matcher`s.
65//!
66//! ```no_run
67//! # use frontest::prelude::*;
68//! # use gloo::utils::{document, body};
69//! # use web_sys::HtmlElement;
70//! struct IsHidden;
71//!
72//! impl Matcher for IsHidden {
73//!     fn matches(&self, elem: &HtmlElement) -> bool {
74//!         elem.hidden()
75//!     }
76//! }
77//!
78//! let div = document().create_element("div").unwrap();
79//! div.set_inner_html(
80//!     r#"<button hidden>
81//!         Yayyy frontend in rust!
82//!     </button>"#
83//! );
84//! div.append_child(&div).unwrap();
85//!
86//! let hidden_button = div.get(&IsHidden.and(HasRole("button"))).unwrap();
87//!
88//! body().remove_child(&div).unwrap();
89//! ```
90//!
91//! # Integration:
92//! Tests should be run using [`wasm-bindgen-test`]. It allows running them directly in browsers or in node-js.
93//!
94//! Currently this crate provides a [`render`] function that allows for quickly rendering any [`html`] created with [`yew`].
95//! It was choosen to render the html instead of directly taking a component so it is easier to wrap them with [`ContextProvider`] and so on.
96//!
97//! ## Example:
98//! ```no_run
99//! # #[cfg(feature = "yew")]
100//! # {
101//! # use yew::prelude::*;
102//! #[function_component(Incrementable)]
103//! fn incrementable() -> Html {
104//!     let counter = use_state(|| 0);
105//!     let onclick = {
106//!         let counter = counter.clone();
107//!         Callback::from(move |_| counter.set(*counter + 1))
108//!     };
109//!     html! {
110//!         <div>
111//!             <p>{ format!("Value: {}", *counter) }</p>
112//!             <button {onclick}>{ "Add" }</button>
113//!         </div>
114//!     }
115//! }
116//!
117//! # use wasm_bindgen_test::wasm_bindgen_test;
118//! # use gloo::utils::body;
119//! use frontest::prelude::*;
120//! use frontest::yew::render;
121//! use yew::html;
122//!
123//! #[wasm_bindgen_test]
124//! async fn clicking_on_button_should_increment_value() {
125//!     let mount = render(html! { <Incrementable /> }).await;
126//!     let value = mount.get(&HasText("Value:")).unwrap();
127//!     let button = mount.get(&HasRole("button")).unwrap();
128//!
129//!     assert_eq!("Value: 0", value.inner_text());
130//!     button.click();
131//!     assert_eq!("Value: 1", value.inner_text());
132//!
133//!     body().remove_child(&mount).unwrap();
134//! }
135//! # }
136//! ```
137//!
138//! ## Warning:
139//!
140//! [`wasm-bindgen-test`] runs all tests sequentially and let them manipulate real DOM.
141//! However it doesn't recreate full DOM for each test, so things done in one test may impact others.
142//! Always make sure you are doing a proper cleanup of DOM after your tests eg. remove mounted child element.
143//! Hopefully in future this library will provide some kind of RAII for running tests.
144//!
145//! [`dom-testing-library`]: https://testing-library.com/docs/dom-testing-library/intro
146//! [`react-testing-library`]: https://testing-library.com/docs/react-testing-library/intro
147//! [`wasm-bindgen-test`]: https://rustwasm.github.io/wasm-bindgen/wasm-bindgen-test/usage.html
148//! [`render`]: yew::render
149//! [`html`]: ::yew::html!
150//! [`ContextProvider`]: ::yew::context::ContextProvider
151//! [`HtmlElement`]: web_sys::HtmlElement
152//! [`HasText`]: query::HasText
153//! [`HasLabel`]: query::HasLabel
154//! [`HasPlaceholder`]: query::HasPlaceholder
155//! [`HasRole`]: query::HasRole
156//! [`Not`]: query::Not
157//! [`Query`]: query::Query
158//! [`Joinable`]: query::Joinable
159use gloo::timers::future::sleep;
160use std::time::Duration;
161
162#[cfg(test)]
163wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
164
165/// A convenient imports for testing.
166pub mod prelude {
167    pub use crate::query::{And, Not, Or};
168    pub use crate::query::{HasLabel, HasPlaceholder, HasRole, HasText};
169
170    pub use crate::query::{Joinable, Matcher, Query};
171}
172/// Find various elements across the website as the user would.
173pub mod query;
174
175#[cfg(test)]
176#[wasm_bindgen_test::wasm_bindgen_test]
177async fn doctest_basic_usage() {
178    use crate::query::*;
179    use gloo::utils::{body, document};
180
181    let div = document().create_element("div").unwrap();
182    div.set_inner_html(
183        r#"<div>
184            <label>
185                I will start testing my frontend!
186                <button>
187                    Take the red pill
188                </button>
189            </label>
190            <label>
191                It's too problematic dude...
192                <button>
193                    Take the blue pill
194                </button>
195            </label>
196        </div>"#,
197    );
198    body().append_child(&div).unwrap();
199
200    let go_to_matrix = div
201        .get(&HasRole("button").and(Not(HasLabel("It's too problematic dude..."))))
202        .unwrap();
203    go_to_matrix.click();
204
205    body().remove_child(&div).unwrap();
206}
207
208/// A helpers when testing frontend made with [`yew`]
209///
210/// [`yew`]: ::yew
211#[cfg(feature = "yew")]
212pub mod yew {
213    use ::yew::prelude::*;
214    use web_sys::Element;
215
216    #[derive(Properties, PartialEq)]
217    struct WrapperProps {
218        content: Html,
219    }
220
221    #[function_component(Wrapper)]
222    fn wrapper(props: &WrapperProps) -> Html {
223        props.content.clone()
224    }
225
226    /// Render arbitrary output of [`html`] macro, mount it into body and return mount-point [`Element`]
227    ///
228    /// # Example:
229    /// ```no_run
230    /// # use yew::prelude::*;
231    /// #[function_component(Incrementable)]
232    /// fn incrementable() -> Html {
233    ///     let counter = use_state(|| 0);
234    ///     let onclick = {
235    ///         let counter = counter.clone();
236    ///         Callback::from(move |_| counter.set(*counter + 1))
237    ///     };
238    ///     html! {
239    ///         <div>
240    ///             <p>{ format!("Value: {}", *counter) }</p>
241    ///             <button {onclick}>{ "Add" }</button>
242    ///         </div>
243    ///     }
244    /// }
245    ///
246    /// # use wasm_bindgen_test::wasm_bindgen_test;
247    /// # use gloo::utils::body;
248    /// use frontest::prelude::*;
249    /// use frontest::yew::render;
250    /// use yew::html;
251    ///
252    /// #[wasm_bindgen_test]
253    /// async fn clicking_on_button_should_increment_value() {
254    ///     let mount = render(html! { <Incrementable /> }).await;
255    ///     let value = mount.get(&HasText("Value:")).unwrap();
256    ///     let button = mount.get(&HasRole("button")).unwrap();
257    ///
258    ///     assert_eq!("Value: 0", value.inner_text());
259    ///     button.click();
260    ///     assert_eq!("Value: 1", value.inner_text());
261    ///
262    ///     body().remove_child(&mount).unwrap();
263    /// }
264    /// ```
265    ///
266    /// [`html`]: ::yew::html!
267    /// [`element`]: web_sys::Element
268    pub async fn render(content: Html) -> Element {
269        let div = gloo::utils::document().create_element("div").unwrap();
270        gloo::utils::body().append_child(&div).unwrap();
271        let res = div.clone();
272        ::yew::Renderer::<Wrapper>::with_root_and_props(div, WrapperProps { content }).render();
273        ::yew::platform::time::sleep(std::time::Duration::ZERO).await;
274
275        res
276    }
277
278    #[cfg(test)]
279    #[wasm_bindgen_test::wasm_bindgen_test]
280    async fn doctest_yew_render() {
281        use ::yew::prelude::*;
282        #[function_component(Incrementable)]
283        fn incrementable() -> Html {
284            let counter = use_state(|| 0);
285            let onclick = {
286                let counter = counter.clone();
287                Callback::from(move |_| counter.set(*counter + 1))
288            };
289            html! {
290                <div>
291                    <p>{ format!("Value: {}", *counter) }</p>
292                    <button {onclick}>{ "Add" }</button>
293                </div>
294            }
295        }
296
297        use crate::query::{HasRole, HasText, Query};
298        use crate::yew::render;
299        use ::yew::html;
300        // use wasm_bindgen_test::wasm_bindgen_test;
301        use gloo::utils::body;
302
303        // #[wasm_bindgen_test]
304        // async fn clicking_on_button_should_increment_value() {
305        let mount = render(html! { <Incrementable /> }).await;
306        let value = mount.get(&HasText("Value:")).unwrap();
307        let button = mount.get(&HasRole("button")).unwrap();
308
309        assert_eq!("Value: 0", value.inner_text());
310        button.click();
311        // Events are handled when the scheduler is yielded. So we need to add this after any interaction with the DOM.
312        ::yew::platform::time::sleep(std::time::Duration::ZERO).await;
313        assert_eq!("Value: 1", value.inner_text());
314
315        body().remove_child(&mount).unwrap();
316        // }
317    }
318}
319
320/// Preempt execution of current task to let the js's main thread do things like re-render.
321///
322/// # Warning:
323/// I'm currently unsure in which condition tick is required. Most cases should work without the need of using it.
324#[doc(hidden)]
325pub async fn tick() {
326    sleep(Duration::ZERO).await;
327}