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:
214
crates/egui_kittest/src/snapshot.rs
Normal file
214
crates/egui_kittest/src/snapshot.rs
Normal 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(¤t_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user