Skip to content

Commit

Permalink
Add egui testing library (#5166)
Browse files Browse the repository at this point in the history
- closes #3491 
- closes #3926

This adds a testing library to egui based on
[kittest](https://github.com/rerun-io/kittest). Kittest is a new
[AccessKit](https://github.com/AccessKit/accesskit/)-based testing
library. The api is inspired by the js
[testing-library](https://testing-library.com/) where the idea is also
to query the dom based on accessibility attributes.
We made kittest with egui in mind but it should work with any rust gui
framework with AccessKit support.

It currently has support for:
- running the egui app, frame by frame
- building the AccessKit tree
- ergonomic queries via kittest
  - via e.g. get_by_name, get_by_role
- simulating events based on the accesskit node id
- creating arbitrary events based on Harness::input_mut
- rendering screenshots via wgpu
- snapshot tests with these screenshots

A simple test looks like this: 
```rust
fn main() {
    let mut checked = false;
    let app = |ctx: &Context| {
        CentralPanel::default().show(ctx, |ui| {
            ui.checkbox(&mut checked, "Check me!");
        });
    };

    let mut harness = Harness::builder().with_size(egui::Vec2::new(200.0, 100.0)).build(app);
    
    let checkbox = harness.get_by_name("Check me!");
    assert_eq!(checkbox.toggled(), Some(Toggled::False));
    checkbox.click();
    
    harness.run();

    let checkbox = harness.get_by_name("Check me!");
    assert_eq!(checkbox.toggled(), Some(Toggled::True));

    // You can even render the ui and do image snapshot tests
    #[cfg(all(feature = "wgpu", feature = "snapshot"))]
    egui_kittest::image_snapshot(&egui_kittest::wgpu::TestRenderer::new().render(&harness), "readme_example");
}
```

~Since getting wgpu to run in ci is a hassle, I'm taking another shot at
creating a software renderer for egui (ideally without a huge dependency
like skia)~ (this didn't work as well as I hoped and it turns out in CI
you can just run tests on a mac runner which comes with a real GPU)
 
Here is a example of a failed snapshot test in ci, it will say which
snapshot failed and upload an artifact with the before / after and diff
images:

https://github.com/emilk/egui/actions/runs/11183049487/job/31090724606?pr=5166
  • Loading branch information
lucasmerlin authored Oct 22, 2024
1 parent 707cd03 commit 70a0113
Show file tree
Hide file tree
Showing 45 changed files with 1,262 additions and 11 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
* text=auto eol=lf
Cargo.lock linguist-generated=false
**/tests/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text
41 changes: 35 additions & 6 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
lfs: true

- uses: dtolnay/rust-toolchain@master
with:
Expand Down Expand Up @@ -60,18 +62,12 @@ jobs:
- name: cargo check -p test_egui_extras_compilation
run: cargo check -p test_egui_extras_compilation

- name: Test doc-tests
run: cargo test --doc --all-features

- name: cargo doc --lib
run: cargo doc --lib --no-deps --all-features

- name: cargo doc --document-private-items
run: cargo doc --document-private-items --no-deps --all-features

- name: Test
run: cargo test --all-features

- name: clippy
run: cargo clippy --all-targets --all-features -- -D warnings

Expand Down Expand Up @@ -222,3 +218,36 @@ jobs:

- name: Check hello_world
run: cargo check -p hello_world

# ---------------------------------------------------------------------------

tests:
name: Run tests
# We run the tests on macOS because it will run with a actual GPU
runs-on: macos-latest

steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.76.0

- name: Set up cargo cache
uses: Swatinem/rust-cache@v2

- name: Run tests
# TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature)
run: cargo test

- name: Run doc-tests
# TODO(lucasmerlin): Enable --all-features (currently this breaks the rendering in the tests because of the `unity` feature)
run: cargo test --doc

- name: Upload artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: "**/tests/snapshots"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
**/target
**/target_ra
**/target_wasm
**/tests/snapshots/**/*.diff.png
**/tests/snapshots/**/*.new.png
/.*.json
/.vscode
/media/*
Expand Down
77 changes: 74 additions & 3 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,12 @@ version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"

[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"

[[package]]
name = "bytes"
version = "1.5.0"
Expand Down Expand Up @@ -818,6 +824,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"

[[package]]
name = "colored"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8"
dependencies = [
"lazy_static",
"windows-sys 0.48.0",
]

[[package]]
name = "com"
version = "0.6.0"
Expand Down Expand Up @@ -1097,6 +1113,19 @@ dependencies = [
"syn 1.0.109",
]

[[package]]
name = "dify"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11217d469eafa3b809ad84651eb9797ccbb440b4a916d5d85cb1b994e89787f6"
dependencies = [
"anyhow",
"colored",
"getopts",
"image",
"rayon",
]

[[package]]
name = "digest"
version = "0.10.7"
Expand Down Expand Up @@ -1304,9 +1333,12 @@ dependencies = [
"criterion",
"document-features",
"egui",
"egui_demo_lib",
"egui_extras",
"egui_kittest",
"serde",
"unicode_names2",
"wgpu",
]

[[package]]
Expand Down Expand Up @@ -1348,6 +1380,20 @@ dependencies = [
"winit",
]

[[package]]
name = "egui_kittest"
version = "0.29.1"
dependencies = [
"dify",
"document-features",
"egui",
"egui-wgpu",
"image",
"kittest",
"pollster",
"wgpu",
]

[[package]]
name = "ehttp"
version = "0.5.0"
Expand Down Expand Up @@ -1767,6 +1813,15 @@ dependencies = [
"windows-targets 0.48.5",
]

[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]

[[package]]
name = "getrandom"
version = "0.2.10"
Expand Down Expand Up @@ -2130,12 +2185,12 @@ dependencies = [

[[package]]
name = "image"
version = "0.25.0"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9b4f005360d32e9325029b38ba47ebd7a56f3316df09249368939562d518645"
checksum = "99314c8a2152b8ddb211f924cdae532d8c5e4c8bb54728e12fff1b0cd5963a10"
dependencies = [
"bytemuck",
"byteorder",
"byteorder-lite",
"color_quant",
"gif",
"num-traits",
Expand Down Expand Up @@ -2290,6 +2345,16 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"

[[package]]
name = "kittest"
version = "0.1.0"
source = "git+https://github.com/rerun-io/kittest?branch=main#1336a504aefd05f7e9aa7c9237ae44ba9e72acdd"
dependencies = [
"accesskit",
"accesskit_consumer",
"parking_lot",
]

[[package]]
name = "kurbo"
version = "0.9.5"
Expand All @@ -2299,6 +2364,12 @@ dependencies = [
"arrayvec",
]

[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"

[[package]]
name = "libc"
version = "0.2.155"
Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"crates/egui_demo_lib",
"crates/egui_extras",
"crates/egui_glow",
"crates/egui_kittest",
"crates/egui-wgpu",
"crates/egui-winit",
"crates/egui",
Expand Down Expand Up @@ -64,6 +65,7 @@ egui_extras = { version = "0.29.1", path = "crates/egui_extras", default-feature
egui-wgpu = { version = "0.29.1", path = "crates/egui-wgpu", default-features = false }
egui_demo_lib = { version = "0.29.1", path = "crates/egui_demo_lib", default-features = false }
egui_glow = { version = "0.29.1", path = "crates/egui_glow", default-features = false }
egui_kittest = { version = "0.29.1", path = "crates/egui_kittest", default-features = false }
eframe = { version = "0.29.1", path = "crates/eframe", default-features = false }

ahash = { version = "0.8.11", default-features = false, features = [
Expand All @@ -73,15 +75,18 @@ ahash = { version = "0.8.11", default-features = false, features = [
backtrace = "0.3"
bytemuck = "1.7.2"
criterion = { version = "0.5.1", default-features = false }
dify = { version = "0.7", default-features = false }
document-features = " 0.2.8"
glow = "0.14"
glutin = "0.32.0"
glutin-winit = "0.5.0"
home = "0.5.9"
image = { version = "0.25", default-features = false }
kittest = { git = "https://github.com/rerun-io/kittest", version = "0.1", branch = "main"}
log = { version = "0.4", features = ["std"] }
nohash-hasher = "0.2"
parking_lot = "0.12"
pollster = "0.3"
puffin = "0.19"
puffin_http = "0.16"
ron = "0.8"
Expand Down
7 changes: 6 additions & 1 deletion crates/egui_demo_lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,13 @@ serde = { workspace = true, optional = true }


[dev-dependencies]
criterion.workspace = true
# when running tests we always want to use the `chrono` feature
egui_demo_lib = { workspace = true, features = ["chrono"] }

criterion.workspace = true
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
wgpu = { workspace = true, features = ["metal"] }
egui = { workspace = true, features = ["default_fonts"] }

[[bench]]
name = "benchmark"
Expand Down
49 changes: 49 additions & 0 deletions crates/egui_demo_lib/src/demo/demo_app_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,3 +377,52 @@ fn file_menu_button(ui: &mut Ui) {
}
});
}

#[cfg(test)]
mod tests {
use crate::demo::demo_app_windows::Demos;
use egui::Vec2;
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;

#[test]
fn demos_should_match_snapshot() {
let demos = Demos::default();

let mut errors = Vec::new();

for mut demo in demos.demos {
// Remove the emoji from the demo name
let name = demo
.name()
.split_once(' ')
.map_or(demo.name(), |(_, name)| name);

// Widget Gallery needs to be customized (to set a specific date) and has its own test
if name == "Widget Gallery" {
continue;
}

let mut harness = Harness::new(|ctx| {
demo.show(ctx, &mut true);
});

let window = harness.node().children().next().unwrap();
// TODO(lucasmerlin): Windows should probably have a label?
//let window = harness.get_by_name(name);

let size = window.raw_bounds().expect("window bounds").size();
harness.set_size(Vec2::new(size.width as f32, size.height as f32));

// Run the app for some more frames...
harness.run();

let result = harness.try_wgpu_snapshot(&format!("demos/{name}"));
if let Err(err) = result {
errors.push(err);
}
}

assert!(errors.is_empty(), "Errors: {errors:#?}");
}
}
34 changes: 34 additions & 0 deletions crates/egui_demo_lib/src/demo/text_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,37 @@ impl crate::View for TextEditDemo {
});
}
}

#[cfg(test)]
mod tests {
use egui::{accesskit, CentralPanel};
use egui_kittest::kittest::{Key, Queryable};
use egui_kittest::Harness;

#[test]
pub fn should_type() {
let mut text = "Hello, world!".to_owned();
let mut harness = Harness::new(move |ctx| {
CentralPanel::default().show(ctx, |ui| {
ui.text_edit_singleline(&mut text);
});
});

harness.run();

let text_edit = harness.get_by_role(accesskit::Role::TextInput);
assert_eq!(text_edit.value().as_deref(), Some("Hello, world!"));

text_edit.key_combination(&[Key::Command, Key::A]);
text_edit.type_text("Hi ");

harness.run();
harness
.get_by_role(accesskit::Role::TextInput)
.type_text("there!");

harness.run();
let text_edit = harness.get_by_role(accesskit::Role::TextInput);
assert_eq!(text_edit.value().as_deref(), Some("Hi there!"));
}
}
28 changes: 28 additions & 0 deletions crates/egui_demo_lib/src/demo/widget_gallery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,31 @@ fn doc_link_label_with_crate<'a>(
})
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::View;
use egui::{CentralPanel, Context, Vec2};
use egui_kittest::Harness;

#[test]
pub fn should_match_screenshot() {
let mut demo = WidgetGallery {
// If we don't set a fixed date, the snapshot test will fail.
date: Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
..Default::default()
};
let app = |ctx: &Context| {
CentralPanel::default().show(ctx, |ui| {
demo.ui(ui);
});
};
let harness = Harness::builder()
.with_size(Vec2::new(380.0, 550.0))
.with_dpi(2.0)
.build(app);

harness.wgpu_snapshot("widget_gallery");
}
}
Loading

0 comments on commit 70a0113

Please sign in to comment.