Testing without a window
At the end of this chapter, cargo test clicks buttons, presses a key
and reads the screen — with no window.
Why it works
The core owns no window. A frame is your view run against a viewport
and a clock the caller provides. Input is a value handed in. So a test
can run the real view and the real on_event, and assert on what the
frame produced, the way a user would see it.
kui_native::testing::Drive is the driver.
The app under test
A smaller cousin of chapter 6’s list: an Add button, a Clear button,
a row per item, and a summary line. Two boxes are given keys — list
and summary — so the test can find them by name; the buttons and the
checkboxes are found by their text, since the stock widgets key
themselves that way.
//! Step 10 — a test without a window. Builds on step 5's list by giving
//! it a `mod tests` that drives the real `view` and `on_event` headless:
//! clicks by label, a key, and assertions on what the frame drew.
//! Chapter 11 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_10_testing
//! Test: cargo test -p kui-native --example tutorial_10_testing
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
Toggle {
id: u64,
},
Clear,
/// The root sink: `a` adds, so a test can press a key too.
Key,
}
struct Item {
id: u64,
text: String,
done: bool,
}
#[derive(Default)]
struct Todo {
items: Vec<Item>,
next_id: u64,
}
impl Todo {
fn add(&mut self) {
self.next_id += 1;
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "Clear", Msg::Clear);
});
// A keyed box has a name the frame remembers. A test reads
// the frame by those names, as it clicks a button by its
// text.
ui.with_keyed("list", NodeSpec::column().gap(4.0), |ui| {
for item in &self.items {
ui.with_indexed(item.id, NodeSpec::row(), |ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
});
}
});
let done = self.items.iter().filter(|i| i.done).count();
ui.text_in_keyed(
"summary",
NodeSpec::row(),
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Clear) => self.items.clear(),
Some(Msg::Key) => {
if let Some((KeyPhase::Down, key)) = ev.key_press()
&& key.code == KeyCode::Char('a')
{
self.add();
}
}
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Testing")
.size(360.0, 300.0)
.run(Todo::default())
}
#[cfg(test)]
mod tests {
use super::*;
use kui_native::testing::Drive;
use kui_native::{Core, KeyMods};
/// A drive over a core and a viewport: the same `view` and `on_event`
/// the window runs, with no window. `framing` builds a frame after
/// every gesture, so the view has caught up before the next assert.
fn drive() -> Drive {
Drive::new(Core::new(), 360.0, 300.0).framing()
}
#[test]
fn a_click_adds_and_a_click_ticks() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
assert_eq!(d.texts_under("summary"), ["0 of 0 done"]);
// Click by label: how a screen reader presses, so no geometry.
let add = d.key_of("Add").expect("an Add button");
d.click_key(&mut app, add);
d.click_key(&mut app, add);
assert_eq!(app.items.len(), 2);
assert_eq!(d.texts_under("list"), ["Task 1", "Task 2"]);
let first = d.key_of("Task 1").expect("the first row's checkbox");
d.click_key(&mut app, first);
assert!(app.items[0].done);
assert_eq!(d.texts_under("summary"), ["1 of 2 done"]);
}
#[test]
fn a_key_adds_too() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
d.key(&mut app, "a", KeyMods::NONE);
assert_eq!(app.items.len(), 1);
// Nothing the core saw was misdeclared.
assert!(d.warnings().is_empty());
}
}
The tests
//! Step 10 — a test without a window. Builds on step 5's list by giving
//! it a `mod tests` that drives the real `view` and `on_event` headless:
//! clicks by label, a key, and assertions on what the frame drew.
//! Chapter 11 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_10_testing
//! Test: cargo test -p kui-native --example tutorial_10_testing
use kui_native::widgets;
use kui_native::{Align, App, KeyCode, KeyPhase, Message, NodeSpec, TextStyle, Ui, UiEvent};
#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
Add,
Toggle {
id: u64,
},
Clear,
/// The root sink: `a` adds, so a test can press a key too.
Key,
}
struct Item {
id: u64,
text: String,
done: bool,
}
#[derive(Default)]
struct Todo {
items: Vec<Item>,
next_id: u64,
}
impl Todo {
fn add(&mut self) {
self.next_id += 1;
self.items.push(Item {
id: self.next_id,
text: format!("Task {}", self.next_id),
done: false,
});
}
}
impl App for Todo {
fn view(&mut self, ui: &mut Ui<'_>) {
let t = ui.theme();
let sink = ui.with_keyed(
"app",
NodeSpec::column()
.fill()
.pad(20.0)
.gap(12.0)
.cross_align(Align::Start)
.bg(t.bg)
.on_key(Msg::Key),
|ui| {
ui.with(NodeSpec::row().gap(8.0), |ui| {
widgets::button(ui, "Add", Msg::Add);
widgets::button(ui, "Clear", Msg::Clear);
});
// A keyed box has a name the frame remembers. A test reads
// the frame by those names, as it clicks a button by its
// text.
ui.with_keyed("list", NodeSpec::column().gap(4.0), |ui| {
for item in &self.items {
ui.with_indexed(item.id, NodeSpec::row(), |ui| {
widgets::checkbox(
ui,
&item.text,
item.done,
Msg::Toggle { id: item.id },
);
});
}
});
let done = self.items.iter().filter(|i| i.done).count();
ui.text_in_keyed(
"summary",
NodeSpec::row(),
&format!("{done} of {} done", self.items.len()),
TextStyle::new(12.0).color(t.muted),
);
},
);
ui.take_key_focus(sink);
}
fn on_event(&mut self, ev: UiEvent) {
match ev.message::<Msg>() {
Some(Msg::Add) => self.add(),
Some(Msg::Toggle { id }) => {
if let Some(item) = self.items.iter_mut().find(|i| i.id == id) {
item.done = !item.done;
}
}
Some(Msg::Clear) => self.items.clear(),
Some(Msg::Key) => {
if let Some((KeyPhase::Down, key)) = ev.key_press()
&& key.code == KeyCode::Char('a')
{
self.add();
}
}
None => {}
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
kui_native::app("Testing")
.size(360.0, 300.0)
.run(Todo::default())
}
#[cfg(test)]
mod tests {
use super::*;
use kui_native::testing::Drive;
use kui_native::{Core, KeyMods};
/// A drive over a core and a viewport: the same `view` and `on_event`
/// the window runs, with no window. `framing` builds a frame after
/// every gesture, so the view has caught up before the next assert.
fn drive() -> Drive {
Drive::new(Core::new(), 360.0, 300.0).framing()
}
#[test]
fn a_click_adds_and_a_click_ticks() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
assert_eq!(d.texts_under("summary"), ["0 of 0 done"]);
// Click by label: how a screen reader presses, so no geometry.
let add = d.key_of("Add").expect("an Add button");
d.click_key(&mut app, add);
d.click_key(&mut app, add);
assert_eq!(app.items.len(), 2);
assert_eq!(d.texts_under("list"), ["Task 1", "Task 2"]);
let first = d.key_of("Task 1").expect("the first row's checkbox");
d.click_key(&mut app, first);
assert!(app.items[0].done);
assert_eq!(d.texts_under("summary"), ["1 of 2 done"]);
}
#[test]
fn a_key_adds_too() {
let mut app = Todo::default();
let mut d = drive();
d.frame(&mut app);
d.key(&mut app, "a", KeyMods::NONE);
assert_eq!(app.items.len(), 1);
// Nothing the core saw was misdeclared.
assert!(d.warnings().is_empty());
}
}
Run them:
cargo test
What the drive does
Drive::new(core, w, h)— a core and a viewport..framing()makes it build a frame after every gesture, so the view has caught up before the next line.d.frame(&mut app)— one frame, as the runner would build it.d.key_of("Add")— the key of the node declared under that name. Buttons are keyed by their text; your own boxes by what you gavewith_keyedortext_in_keyed.d.click_key(&mut app, key)— a click by key, the way a screen reader presses. No coordinates.d.click(app, x, y)is the pointer version, andd.rect_of(key)gives you the rect to aim at.d.key(&mut app, "a", KeyMods::NONE)— a key press and release, by name.d.keys(app, "jj ww")types a sequence.d.texts_under("list")— every text inside the node with that key, in order. What the user would read.d.warnings()— every warning the core raised. A test that asserts it empty catches an unnamed button or a duplicate key before a user does.
There is more — hover, drag, wheel, advance(secs) to move the
clock for a transition — in the module’s docs.
What to assert on
Two things, and both are cheap here:
- the model —
app.items.len()— because that is what the app believes; - the frame —
texts_under,rect_of— because that is what the user sees, and a view that ignores its model would pass the first assert and fail this one.
Try this
- Assert that the second row’s checkbox is not ticked after ticking
the first. (
app.items[1].done, and the frame: the checkbox’scheckedis in the node’s access tree —d.core.access_tree().) - Add
d.advance(1.0)and a frame, then assert something about a transition. Chapter 9’s toasts are a good subject.
Where this is decided
- The headless driver:
kui_native::testing. - The Node version of the same test:
docs/guide.md, Testing without a window. - A larger example with its own
mod tests:apps/splitmux.rs.