diff --git a/Cargo.lock b/Cargo.lock index d77b3e082e..85fab142e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1064,6 +1064,7 @@ name = "counter" version = "0.1.0" dependencies = [ "iced", + "iced_test", ] [[package]] @@ -2517,6 +2518,7 @@ dependencies = [ "iced_runtime", "png", "sha2", + "thiserror 1.0.69", ] [[package]] diff --git a/examples/counter/Cargo.toml b/examples/counter/Cargo.toml index e916a1a14b..02eac32983 100644 --- a/examples/counter/Cargo.toml +++ b/examples/counter/Cargo.toml @@ -11,3 +11,6 @@ iced.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] iced.workspace = true iced.features = ["webgl", "fira-sans"] + +[dev-dependencies] +iced_test.workspace = true diff --git a/examples/counter/src/main.rs b/examples/counter/src/main.rs index 81684c1cd9..35d6f0e59e 100644 --- a/examples/counter/src/main.rs +++ b/examples/counter/src/main.rs @@ -38,3 +38,32 @@ impl Counter { .align_x(Center) } } + +#[cfg(test)] +mod tests { + use super::*; + use iced_test::selector::text; + use iced_test::{simulator, Error}; + + #[test] + fn it_counts() -> Result<(), Error> { + let mut counter = Counter { value: 0 }; + let mut ui = simulator(counter.view()); + + let _ = ui.click(text("Increment")); + let _ = ui.click(text("Increment")); + let _ = ui.click(text("Decrement")); + + for message in ui.into_messages() { + counter.update(message); + } + + assert_eq!(counter.value, 1); + + let mut ui = simulator(counter.view()); + + assert!(ui.find(text("1")).is_ok(), "Counter should display 1!"); + + Ok(()) + } +} diff --git a/examples/todos/src/main.rs b/examples/todos/src/main.rs index 45034d6cad..a5bca235be 100644 --- a/examples/todos/src/main.rs +++ b/examples/todos/src/main.rs @@ -591,7 +591,8 @@ mod tests { use super::*; use iced::{Settings, Theme}; - use iced_test::{selector, Error, Simulator}; + use iced_test::selector::text; + use iced_test::{Error, Simulator}; fn simulator(todos: &Todos) -> Simulator { Simulator::with_settings( @@ -619,7 +620,7 @@ mod tests { } let mut ui = simulator(&todos); - let _ = ui.find(selector::text("Create the universe"))?; + let _ = ui.find(text("Create the universe"))?; let snapshot = ui.snapshot(&Theme::Dark)?; assert!( diff --git a/test/Cargo.toml b/test/Cargo.toml index ff6cb38a37..2dd35e7f39 100644 --- a/test/Cargo.toml +++ b/test/Cargo.toml @@ -21,3 +21,4 @@ iced_renderer.features = ["fira-sans"] png.workspace = true sha2.workspace = true +thiserror.workspace = true diff --git a/test/src/lib.rs b/test/src/lib.rs index e5ed040ddf..9756e42769 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -1,5 +1,91 @@ //! Test your `iced` applications in headless mode. -#![allow(missing_docs, missing_debug_implementations)] +//! +//! # Basic Usage +//! Let's assume we want to test [the classical counter interface]. +//! +//! First, we will want to create a [`Simulator`] of our interface: +//! +//! ```rust,no_run +//! # struct Counter { value: i64 } +//! # impl Counter { +//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } +//! # } +//! use iced_test::simulator; +//! +//! let mut counter = Counter { value: 0 }; +//! let mut ui = simulator(counter.view()); +//! ``` +//! +//! Now we can simulate a user interacting with our interface. Let's use [`Simulator::click`] to click +//! the counter buttons: +//! +//! ```rust,no_run +//! # struct Counter { value: i64 } +//! # impl Counter { +//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } +//! # } +//! use iced_test::selector::text; +//! # use iced_test::simulator; +//! # +//! # let mut counter = Counter { value: 0 }; +//! # let mut ui = simulator(counter.view()); +//! +//! let _ = ui.click(text("+")); +//! let _ = ui.click(text("+")); +//! let _ = ui.click(text("-")); +//! ``` +//! +//! [`Simulator::click`] takes a [`Selector`]. A [`Selector`] describes a way to query the widgets of an interface. In this case, +//! [`selector::text`] lets us select a widget by the text it contains. +//! +//! We can now process any messages produced by these interactions, and then make sure that the final value of our counter is +//! indeed `1`! +//! +//! ```rust,no_run +//! # struct Counter { value: i64 } +//! # impl Counter { +//! # pub fn update(&mut self, message: ()) {} +//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } +//! # } +//! # use iced_test::selector::text; +//! # use iced_test::simulator; +//! # +//! # let mut counter = Counter { value: 0 }; +//! # let mut ui = simulator(counter.view()); +//! # +//! # let _ = ui.click(text("+")); +//! # let _ = ui.click(text("+")); +//! # let _ = ui.click(text("-")); +//! # +//! for message in ui.into_messages() { +//! counter.update(message); +//! } +//! +//! assert_eq!(counter.value, 1); +//! ``` +//! +//! We can even rebuild the interface to make sure the counter _displays_ the proper value with [`Simulator::find`]: +//! +//! ```rust,no_run +//! # struct Counter { value: i64 } +//! # impl Counter { +//! # pub fn view(&self) -> iced_runtime::core::Element<(), iced_runtime::core::Theme, iced_renderer::Renderer> { unimplemented!() } +//! # } +//! # use iced_test::selector::text; +//! # use iced_test::simulator; +//! # +//! # let mut counter = Counter { value: 0 }; +//! let mut ui = simulator(counter.view()); +//! +//! assert!(ui.find(text("1")).is_ok(), "Counter should display 1!"); +//! ``` +//! +//! And that's it! That's the gist of testing `iced` applications! +//! +//! [`Simulator`] contains additional operations you can use to simulate more interactions—like [`tap_key`](Simulator::tap_key) or +//! [`typewrite`](Simulator::typewrite)—and even perform [_snapshot testing_](Simulator::snapshot)! +//! +//! [the classical counter interface]: https://book.iced.rs/architecture.html#dissecting-an-interface pub mod selector; pub use selector::Selector; @@ -28,6 +114,9 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; +/// Creates a new [`Simulator`]. +/// +/// This is just a function version of [`Simulator::new`]. pub fn simulator<'a, Message, Theme, Renderer>( element: impl Into>, ) -> Simulator<'a, Message, Theme, Renderer> @@ -38,15 +127,8 @@ where Simulator::new(element) } -fn load_font(font: impl Into>) -> Result<(), Error> { - renderer::graphics::text::font_system() - .write() - .expect("Write to font system") - .load_font(font.into()); - - Ok(()) -} - +/// A user interface that can be interacted with and inspected programmatically. +#[allow(missing_debug_implementations)] pub struct Simulator< 'a, Message, @@ -60,7 +142,10 @@ pub struct Simulator< messages: Vec, } +/// A specific area of a [`Simulator`], normally containing a widget. +#[derive(Debug, Clone, Copy, PartialEq)] pub struct Target { + /// The bounds of the area. pub bounds: Rectangle, } @@ -69,12 +154,14 @@ where Theme: theme::Base, Renderer: core::Renderer + core::renderer::Headless, { + /// Creates a new [`Simulator`] with default [`Settings`] and a default size (1024x768). pub fn new( element: impl Into>, ) -> Self { Self::with_settings(Settings::default(), element) } + /// Creates a new [`Simulator`] with the given [`Settings`] and a default size (1024x768). pub fn with_settings( settings: Settings, element: impl Into>, @@ -82,6 +169,7 @@ where Self::with_size(settings, window::Settings::default().size, element) } + /// Creates a new [`Simulator`] with the given [`Settings`] and size. pub fn with_size( settings: Settings, size: impl Into, @@ -117,6 +205,7 @@ where } } + /// Finds the [`Target`] of the given widget [`Selector`] in the [`Simulator`]. pub fn find( &mut self, selector: impl Into, @@ -265,10 +354,18 @@ where } } + /// Points the mouse cursor at the given position in the [`Simulator`]. + /// + /// This does _not_ produce mouse movement events! pub fn point_at(&mut self, position: impl Into) { self.cursor = mouse::Cursor::Available(position.into()); } + /// Clicks the [`Target`] found by the given [`Selector`], if any. + /// + /// This consists in: + /// - Pointing the mouse cursor at the center of the [`Target`]. + /// - Simulating a [`click`]. pub fn click( &mut self, selector: impl Into, @@ -281,6 +378,7 @@ where Ok(target) } + /// Simulates a key press, followed by a release, in the [`Simulator`]. pub fn tap_key(&mut self, key: impl Into) -> event::Status { self.simulate(tap_key(key, None)) .first() @@ -288,6 +386,7 @@ where .unwrap_or(event::Status::Ignored) } + /// Simulates a user typing in the keyboard the given text in the [`Simulator`]. pub fn typewrite(&mut self, text: &str) -> event::Status { let statuses = self.simulate(typewrite(text)); @@ -296,6 +395,7 @@ where .fold(event::Status::Ignored, event::Status::merge) } + /// Simulates the given raw sequence of events in the [`Simulator`]. pub fn simulate( &mut self, events: impl IntoIterator, @@ -313,6 +413,7 @@ where statuses } + /// Draws and takes a [`Snapshot`] of the interface in the [`Simulator`]. pub fn snapshot(&mut self, theme: &Theme) -> Result { let base = theme.base(); @@ -357,16 +458,24 @@ where }) } + /// Turns the [`Simulator`] into the sequence of messages produced by any interactions. pub fn into_messages(self) -> impl Iterator { self.messages.into_iter() } } +/// A frame of a user interface rendered by a [`Simulator`]. +#[derive(Debug, Clone)] pub struct Snapshot { screenshot: window::Screenshot, } impl Snapshot { + /// Compares the [`Snapshot`] with the PNG image found in the given path, returning + /// `true` if they are identical. + /// + /// If the PNG image does not exist, it will be created by the [`Snapshot`] for future + /// testing and `true` will be returned. pub fn matches_image(&self, path: impl AsRef) -> Result { let path = snapshot_path(path, "png"); @@ -401,6 +510,11 @@ impl Snapshot { } } + /// Compares the [`Snapshot`] with the SHA-256 hash file found in the given path, returning + /// `true` if they are identical. + /// + /// If the hash file does not exist, it will be created by the [`Snapshot`] for future + /// testing and `true` will be returned. pub fn matches_hash(&self, path: impl AsRef) -> Result { use sha2::{Digest, Sha256}; @@ -427,6 +541,7 @@ impl Snapshot { } } +/// Returns the sequence of events of a click. pub fn click() -> impl Iterator { [ Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), @@ -435,6 +550,7 @@ pub fn click() -> impl Iterator { .into_iter() } +/// Returns the sequence of events of a "key tap" (i.e. pressing and releasing a key). pub fn tap_key( key: impl Into, text: Option, @@ -465,17 +581,27 @@ pub fn tap_key( .into_iter() } +/// Returns the sequence of events of typewriting the given text in a keyboard. pub fn typewrite(text: &str) -> impl Iterator + '_ { text.chars() .map(|c| SmolStr::new_inline(&c.to_string())) .flat_map(|c| tap_key(keyboard::Key::Character(c.clone()), Some(c))) } -#[derive(Debug, Clone)] +/// A test error. +#[derive(Debug, Clone, thiserror::Error)] pub enum Error { + /// No matching widget was found for the [`Selector`]. + #[error("no matching widget was found for the selector: {0:?}")] NotFound(Selector), + /// An IO operation failed. + #[error("an IO operation failed: {0}")] IOFailed(Arc), + /// The decoding of some PNG image failed. + #[error("the decoding of some PNG image failed: {0}")] PngDecodingFailed(Arc), + /// The encoding of some PNG image failed. + #[error("the encoding of some PNG image failed: {0}")] PngEncodingFailed(Arc), } @@ -497,6 +623,15 @@ impl From for Error { } } +fn load_font(font: impl Into>) -> Result<(), Error> { + renderer::graphics::text::font_system() + .write() + .expect("Write to font system") + .load_font(font.into()); + + Ok(()) +} + fn snapshot_path(path: impl AsRef, extension: &str) -> PathBuf { path.as_ref().with_extension(extension) } diff --git a/test/src/selector.rs b/test/src/selector.rs index 54faa1a951..7b8dcb7e6b 100644 --- a/test/src/selector.rs +++ b/test/src/selector.rs @@ -1,9 +1,13 @@ +//! Select widgets of a user interface. use crate::core::text; use crate::core::widget; +/// A selector describes a strategy to find a certain widget in a user interface. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Selector { + /// Find the widget with the given [`widget::Id`]. Id(widget::Id), + /// Find the widget containing the given [`text::Fragment`]. Text(text::Fragment<'static>), } @@ -19,6 +23,7 @@ impl From<&'static str> for Selector { } } +/// Creates [`Selector`] that finds the widget containing the given text fragment. pub fn text(fragment: impl text::IntoFragment<'static>) -> Selector { Selector::Text(fragment.into_fragment()) }