1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 23:00:04 -04:00

Add egui testing library (#5166)

- 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
This commit is contained in:
lucasmerlin
2024-10-22 12:39:00 +02:00
committed by GitHub
parent 707cd03357
commit 70a01138b7
45 changed files with 1262 additions and 11 deletions

View File

@@ -0,0 +1,214 @@
use crate::Harness;
use image::ImageError;
use std::fmt::Display;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum SnapshotError {
/// Image did not match snapshot
Diff {
/// Count of pixels that were different
diff: i32,
/// Path where the diff image was saved
diff_path: PathBuf,
},
/// Error opening the existing snapshot (it probably doesn't exist, check the
/// [`ImageError`] for more information)
OpenSnapshot {
/// Path where the snapshot was expected to be
path: PathBuf,
/// The error that occurred
err: ImageError,
},
/// The size of the image did not match the snapshot
SizeMismatch {
/// Expected size
expected: (u32, u32),
/// Actual size
actual: (u32, u32),
},
/// Error writing the snapshot output
WriteSnapshot {
/// Path where a file was expected to be written
path: PathBuf,
/// The error that occurred
err: ImageError,
},
}
impl Display for SnapshotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Diff { diff, diff_path } => {
write!(
f,
"Image did not match snapshot. Diff: {diff}, {diff_path:?}"
)
}
Self::OpenSnapshot { path, err } => match err {
ImageError::IoError(io) => match io.kind() {
ErrorKind::NotFound => {
write!(f, "Missing snapshot: {path:?}")
}
err => {
write!(f, "Error reading snapshot: {err:?}\nAt: {path:?}")
}
},
err => {
write!(f, "Error decoding snapshot: {err:?}\nAt: {path:?}")
}
},
Self::SizeMismatch { expected, actual } => {
write!(
f,
"Image size did not match snapshot. Expected: {expected:?}, Actual: {actual:?}"
)
}
Self::WriteSnapshot { path, err } => {
write!(f, "Error writing snapshot: {err:?}\nAt: {path:?}")
}
}
}
}
/// Image snapshot test.
///
/// # Errors
/// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error
/// reading or writing the snapshot.
pub fn try_image_snapshot(current: &image::RgbaImage, name: &str) -> Result<(), SnapshotError> {
let snapshots_path = Path::new("tests/snapshots");
let path = snapshots_path.join(format!("{name}.png"));
std::fs::create_dir_all(path.parent().expect("Could not get snapshot folder")).ok();
let diff_path = snapshots_path.join(format!("{name}.diff.png"));
let current_path = snapshots_path.join(format!("{name}.new.png"));
current
.save(&current_path)
.map_err(|err| SnapshotError::WriteSnapshot {
err,
path: current_path,
})?;
let previous = match image::open(&path) {
Ok(image) => image.to_rgba8(),
Err(err) => {
maybe_update_snapshot(&path, current)?;
return Err(SnapshotError::OpenSnapshot { path, err });
}
};
if previous.dimensions() != current.dimensions() {
maybe_update_snapshot(&path, current)?;
return Err(SnapshotError::SizeMismatch {
expected: previous.dimensions(),
actual: current.dimensions(),
});
}
// Looking at dify's source code, the threshold is based on the distance between two colors in
// YIQ color space.
// The default is 0.1, but we'll try 0.0 because ideally the output should not change at all.
// We might have to increase the threshold if there are minor differences when running tests
// on different gpus or different backends.
let threshold = 0.0;
let result = dify::diff::get_results(
previous,
current.clone(),
threshold,
true,
None,
&None,
&None,
);
if let Some((diff, result_image)) = result {
result_image
.save(diff_path.clone())
.map_err(|err| SnapshotError::WriteSnapshot {
path: diff_path.clone(),
err,
})?;
maybe_update_snapshot(&path, current)?;
return Err(SnapshotError::Diff { diff, diff_path });
} else {
// Delete old diff if it exists
std::fs::remove_file(diff_path).ok();
}
Ok(())
}
fn should_update_snapshots() -> bool {
std::env::var("UPDATE_SNAPSHOTS").is_ok()
}
fn maybe_update_snapshot(
snapshot_path: &Path,
current: &image::RgbaImage,
) -> Result<(), SnapshotError> {
if should_update_snapshots() {
current
.save(snapshot_path)
.map_err(|err| SnapshotError::WriteSnapshot {
err,
path: snapshot_path.into(),
})?;
println!("Updated snapshot: {snapshot_path:?}");
}
Ok(())
}
/// Image snapshot test.
///
/// # Panics
/// Panics if the image does not match the snapshot or if there was an error reading or writing the
/// snapshot.
#[track_caller]
pub fn image_snapshot(current: &image::RgbaImage, name: &str) {
match try_image_snapshot(current, name) {
Ok(_) => {}
Err(err) => {
panic!("{}", err);
}
}
}
#[cfg(feature = "wgpu")]
impl Harness<'_> {
/// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot.
///
/// # Errors
/// Returns a [`SnapshotError`] if the image does not match the snapshot or if there was an error
/// reading or writing the snapshot.
#[track_caller]
pub fn try_wgpu_snapshot(&self, name: &str) -> Result<(), SnapshotError> {
let image = crate::wgpu::TestRenderer::new().render(self);
try_image_snapshot(&image, name)
}
/// Render a image using a default [`crate::wgpu::TestRenderer`] and compare it to the snapshot.
///
/// # Panics
/// Panics if the image does not match the snapshot or if there was an error reading or writing the
/// snapshot.
#[track_caller]
pub fn wgpu_snapshot(&self, name: &str) {
match self.try_wgpu_snapshot(name) {
Ok(_) => {}
Err(err) => {
panic!("{}", err);
}
}
}
}