gpui: Add property_test macro (#50935)

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Cameron Mcloughlin 2026-03-06 14:03:45 +00:00 committed by GitHub
parent 66de3d9c00
commit 33e5301946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 656 additions and 111 deletions

313
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -650,6 +650,9 @@ postage = { version = "0.5", features = ["futures-traits"] }
pretty_assertions = { version = "1.3.0", features = ["unstable"] }
proc-macro2 = "1.0.93"
profiling = "1"
# replace this with main when #635 is merged
proptest = { git = "https://github.com/proptest-rs/proptest", rev = "3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b", features = ["attr-macro"] }
proptest-derive = "0.8.0"
prost = "0.9"
prost-build = "0.9"
prost-types = "0.9"

View file

@ -26,6 +26,7 @@ test-support = [
"tree-sitter-rust",
"tree-sitter-typescript",
"tree-sitter-html",
"proptest",
"unindent",
]
@ -63,6 +64,8 @@ ordered-float.workspace = true
parking_lot.workspace = true
pretty_assertions.workspace = true
project.workspace = true
proptest = { workspace = true, optional = true }
proptest-derive = { workspace = true, optional = true }
rand.workspace = true
regex.workspace = true
rpc.workspace = true
@ -110,6 +113,8 @@ lsp = { workspace = true, features = ["test-support"] }
markdown = { workspace = true, features = ["test-support"] }
multi_buffer = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
proptest.workspace = true
proptest-derive.workspace = true
release_channel.workspace = true
rand.workspace = true
semver.workspace = true

View file

@ -76,6 +76,9 @@ fn display_ranges(editor: &Editor, cx: &mut Context<'_, Editor>) -> Vec<Range<Di
.display_ranges(&editor.display_snapshot(cx))
}
#[cfg(any(test, feature = "test-support"))]
pub mod property_test;
#[gpui::test]
fn test_edit_events(cx: &mut TestAppContext) {
init_test(cx, |_| {});

View file

@ -0,0 +1,85 @@
use proptest::prelude::*;
use super::*;
#[derive(Debug, Clone, proptest_derive::Arbitrary)]
pub enum Direction {
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, proptest_derive::Arbitrary)]
pub enum TestAction {
#[proptest(weight = 4)]
Type(String),
Backspace {
#[proptest(strategy = "1usize..100")]
count: usize,
},
Move {
#[proptest(strategy = "1usize..100")]
count: usize,
direction: Direction,
},
}
impl Editor {
pub fn apply_test_action(
&mut self,
action: &TestAction,
window: &mut Window,
cx: &mut Context<Self>,
) {
match action {
TestAction::Type(text) => self.insert(&text, window, cx),
TestAction::Backspace { count } => {
for _ in 0..*count {
self.delete(&Default::default(), window, cx);
}
}
TestAction::Move { count, direction } => {
for _ in 0..*count {
match direction {
Direction::Up => self.move_up(&Default::default(), window, cx),
Direction::Down => self.move_down(&Default::default(), window, cx),
Direction::Left => self.move_left(&Default::default(), window, cx),
Direction::Right => self.move_right(&Default::default(), window, cx),
}
}
}
}
}
}
fn test_actions() -> impl Strategy<Value = Vec<TestAction>> {
proptest::collection::vec(any::<TestAction>(), 1..10)
}
#[gpui::property_test(config = ProptestConfig {cases: 100, ..Default::default()})]
fn editor_property_test(
cx: &mut TestAppContext,
#[strategy = test_actions()] actions: Vec<TestAction>,
) {
init_test(cx, |_| {});
let group_interval = Duration::from_millis(1);
let buffer = cx.new(|cx| {
let mut buf = language::Buffer::local("123456", cx);
buf.set_group_interval(group_interval);
buf
});
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let editor = cx.add_window(|window, cx| build_editor(buffer.clone(), window, cx));
editor
.update(cx, |editor, window, cx| {
for action in actions {
editor.apply_test_action(&action, window, cx);
}
})
.unwrap();
}

View file

@ -24,6 +24,7 @@ test-support = [
"http_client/test-support",
"wayland",
"x11",
"proptest",
]
inspector = ["gpui_macros/inspector"]
leak-detection = ["backtrace"]
@ -64,6 +65,7 @@ num_cpus = "1.13"
parking = "2.0.0"
parking_lot.workspace = true
postage.workspace = true
proptest = { workspace = true, optional = true }
chrono.workspace = true
profiling.workspace = true
rand.workspace = true

View file

@ -54,6 +54,9 @@ mod util;
mod view;
mod window;
#[cfg(any(test, feature = "test-support"))]
pub use proptest;
#[cfg(doc)]
pub mod _ownership_and_data_flow;
@ -86,7 +89,9 @@ pub use elements::*;
pub use executor::*;
pub use geometry::*;
pub use global::*;
pub use gpui_macros::{AppContext, IntoElement, Render, VisualContext, register_action, test};
pub use gpui_macros::{
AppContext, IntoElement, Render, VisualContext, property_test, register_action, test,
};
pub use gpui_util::arc_cow::ArcCow;
pub use http_client;
pub use input::*;

View file

@ -27,12 +27,43 @@
//! ```
use crate::{Entity, Subscription, TestAppContext, TestDispatcher};
use futures::StreamExt as _;
use proptest::prelude::{Just, Strategy, any};
use std::{
env,
panic::{self, RefUnwindSafe},
panic::{self, RefUnwindSafe, UnwindSafe},
pin::Pin,
};
/// Strategy injected into `#[gpui::property_test]` tests to control the seed
/// given to the scheduler. Doesn't shrink, since all scheduler seeds are
/// equivalent in complexity. If `$SEED` is set, it always uses that value.
pub fn seed_strategy() -> impl Strategy<Value = u64> {
match std::env::var("SEED") {
Ok(val) => Just(val.parse().unwrap()).boxed(),
Err(_) => any::<u64>().no_shrink().boxed(),
}
}
/// Similar to [`run_test`], but only runs the callback once, allowing
/// [`FnOnce`] callbacks. This is intended for use with the
/// `gpui::property_test` macro and generally should not be used directly.
///
/// Doesn't support many features of [`run_test`], since these are provided by
/// proptest.
pub fn run_test_once(seed: u64, test_fn: Box<dyn UnwindSafe + FnOnce(TestDispatcher)>) {
let result = panic::catch_unwind(|| {
let dispatcher = TestDispatcher::new(seed);
let scheduler = dispatcher.scheduler().clone();
test_fn(dispatcher);
scheduler.end_test();
});
match result {
Ok(()) => {}
Err(e) => panic::resume_unwind(e),
}
}
/// Run the given test function with the configured parameters.
/// This is intended for use with the `gpui::test` macro
/// and generally should not be used directly.

View file

@ -24,4 +24,4 @@ quote.workspace = true
syn.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["inspector"] }
gpui = { workspace = true, features = ["inspector"] }

View file

@ -3,6 +3,7 @@ mod derive_app_context;
mod derive_into_element;
mod derive_render;
mod derive_visual_context;
mod property_test;
mod register_action;
mod styles;
mod test;
@ -188,6 +189,79 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
test::test(args, function)
}
/// A variant of `#[gpui::test]` that supports property-based testing.
///
/// A property test, much like a standard GPUI randomized test, allows testing
/// claims of the form "for any possible X, Y should hold". For example:
/// ```
/// #[gpui::property_test]
/// fn test_arithmetic(x: i32, y: i32) {
/// assert!(x == y || x < y || x > y);
/// }
/// ```
/// Standard GPUI randomized tests provide you with an instance of `StdRng` to
/// generate random data in a controlled manner. Property-based tests have some
/// advantages, however:
/// - Shrinking - the harness also understands a notion of the "complexity" of a
/// particular value. This allows it to find the "simplest possible value that
/// causes the test to fail".
/// - Ergonomics/clarity - the property-testing harness will automatically
/// generate values, removing the need to fill the test body with generation
/// logic.
/// - Failure persistence - if a failing seed is identified, it is stored in a
/// file, which can be checked in, and future runs will check these cases before
/// future cases.
///
/// Property tests work best when all inputs can be generated up-front and kept
/// in a simple data structure. Sometimes, this isn't possible - for example, if
/// a test needs to make a random decision based on the current state of some
/// structure. In this case, a standard GPUI randomized test may be more
/// suitable.
///
/// ## Customizing random values
///
/// This macro is based on the [`#[proptest::property_test]`] macro, but handles
/// some of the same GPUI-specific arguments as `#[gpui::test]`. Specifically,
/// `&{mut,} TestAppContext` and `BackgroundExecutor` work as normal. `StdRng`
/// arguments are **explicitly forbidden**, since they break shrinking, and are
/// a common footgun.
///
/// All other arguments are forwarded to the underlying proptest macro.
///
/// Note: much of the following is copied from the proptest docs, specifically the
/// [`#[proptest::property_test]`] macro docs.
///
/// Random values of type `T` are generated by a `Strategy<Value = T>` object.
/// Some types have a canonical `Strategy` - these types also implement
/// `Arbitrary`. Parameters to a `#[gpui::property_test]`, by default, use a
/// type's `Arbitrary` implementation. If you'd like to provide a custom
/// strategy, you can use `#[strategy = ...]` on the argument:
/// ```
/// #[gpui::property_test]
/// fn int_test(#[strategy = 1..10] x: i32, #[strategy = "[a-zA-Z0-9]{20}"] s: String) {
/// assert!(s.len() > (x as usize));
/// }
/// ```
///
/// For more information on writing custom `Strategy` and `Arbitrary`
/// implementations, see [the proptest book][book], and the [`Strategy`] trait.
///
/// ## Scheduler
///
/// Similar to `#[gpui::test]`, this macro will choose random seeds for the test
/// scheduler. It uses `.no_shrink()` to tell proptest that all seeds are
/// roughly equivalent in terms of "complexity". If `$SEED` is set, it will
/// affect **ONLY** the seed passed to the scheduler. To control other values,
/// use custom `Strategy`s.
///
/// [`#[proptest::property_test]`]: https://docs.rs/proptest/latest/proptest/attr.property_test.html
/// [book]: https://proptest-rs.github.io/proptest/intro.html
/// [`Strategy`]: https://docs.rs/proptest/latest/proptest/strategy/trait.Strategy.html
#[proc_macro_attribute]
pub fn property_test(args: TokenStream, function: TokenStream) -> TokenStream {
property_test::test(args.into(), function.into()).into()
}
/// When added to a trait, `#[derive_inspector_reflection]` generates a module which provides
/// enumeration and lookup by name of all methods that have the shape `fn method(self) -> Self`.
/// This is used by the inspector so that it can use the builder methods in `Styled` and

View file

@ -0,0 +1,199 @@
use proc_macro2::TokenStream;
use quote::{format_ident, quote, quote_spanned};
use syn::{
FnArg, Ident, ItemFn, Type, parse2, punctuated::Punctuated, spanned::Spanned, token::Comma,
};
pub fn test(args: TokenStream, item: TokenStream) -> TokenStream {
let item_span = item.span();
let Ok(func) = parse2::<ItemFn>(item) else {
return quote_spanned! { item_span =>
compile_error!("#[gpui::property_test] must be placed on a function");
};
};
let test_name = func.sig.ident.clone();
let inner_fn_name = format_ident!("__{test_name}");
let parsed_args = parse_args(func.sig.inputs, &test_name);
let inner_body = func.block;
let inner_arg_decls = parsed_args.inner_fn_decl_args;
let asyncness = func.sig.asyncness;
let inner_fn = quote! {
let #inner_fn_name = #asyncness move |#inner_arg_decls| #inner_body;
};
let arg_errors = parsed_args.errors;
let proptest_args = parsed_args.proptest_args;
let inner_args = parsed_args.inner_fn_args;
let cx_vars = parsed_args.cx_vars;
let cx_teardowns = parsed_args.cx_teardowns;
let proptest_args = quote! {
#[strategy = ::gpui::seed_strategy()] __seed: u64,
#proptest_args
};
let run_test_body = match &asyncness {
None => quote! {
#cx_vars
#inner_fn_name(#inner_args);
#cx_teardowns
},
Some(_) => quote! {
let foreground_executor = gpui::ForegroundExecutor::new(std::sync::Arc::new(dispatcher.clone()));
#cx_vars
foreground_executor.block_test(#inner_fn_name(#inner_args));
#cx_teardowns
},
};
quote! {
#arg_errors
#[::gpui::proptest::property_test(proptest_path = "::gpui::proptest", #args)]
fn #test_name(#proptest_args) {
#inner_fn
::gpui::run_test_once(
__seed,
Box::new(move |dispatcher| {
#run_test_body
}),
)
}
}
}
#[derive(Default)]
struct ParsedArgs {
cx_vars: TokenStream,
cx_teardowns: TokenStream,
proptest_args: TokenStream,
errors: TokenStream,
// exprs passed at the call-site
inner_fn_args: TokenStream,
// args in the declaration
inner_fn_decl_args: TokenStream,
}
fn parse_args(args: Punctuated<FnArg, Comma>, test_name: &Ident) -> ParsedArgs {
let mut parsed = ParsedArgs::default();
let mut args = args.into_iter().collect();
remove_cxs(&mut parsed, &mut args, test_name);
remove_std_rng(&mut parsed, &mut args);
remove_background_executor(&mut parsed, &mut args);
// all remaining args forwarded to proptest's macro
parsed.proptest_args = quote!( #(#args),* );
parsed
}
fn remove_cxs(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>, test_name: &Ident) {
let mut ix = 0;
args.retain_mut(|arg| {
if !is_test_cx(arg) {
return true;
}
let cx_varname = format_ident!("cx_{ix}");
ix += 1;
parsed.cx_vars.extend(quote!(
let mut #cx_varname = gpui::TestAppContext::build(
dispatcher.clone(),
Some(stringify!(#test_name)),
);
));
parsed.cx_teardowns.extend(quote!(
dispatcher.run_until_parked();
#cx_varname.executor().forbid_parking();
#cx_varname.quit();
dispatcher.run_until_parked();
));
parsed.inner_fn_decl_args.extend(quote!(#arg,));
parsed.inner_fn_args.extend(quote!(&mut #cx_varname,));
false
});
}
fn remove_std_rng(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
args.retain_mut(|arg| {
if !is_std_rng(arg) {
return true;
}
parsed.errors.extend(quote_spanned! { arg.span() =>
compile_error!("`StdRng` is not allowed in a property test. Consider implementing `Arbitrary`, or implementing a custom `Strategy`. https://altsysrq.github.io/proptest-book/proptest/tutorial/strategy-basics.html");
});
false
});
}
fn remove_background_executor(parsed: &mut ParsedArgs, args: &mut Vec<FnArg>) {
args.retain_mut(|arg| {
if !is_background_executor(arg) {
return true;
}
parsed.inner_fn_decl_args.extend(quote!(#arg,));
parsed
.inner_fn_args
.extend(quote!(gpui::BackgroundExecutor::new(std::sync::Arc::new(
dispatcher.clone()
)),));
false
});
}
// Matches `&TestAppContext` or `&foo::bar::baz::TestAppContext`
fn is_test_cx(arg: &FnArg) -> bool {
let FnArg::Typed(arg) = arg else {
return false;
};
let Type::Reference(ty) = &*arg.ty else {
return false;
};
let Type::Path(ty) = &*ty.elem else {
return false;
};
ty.path
.segments
.last()
.is_some_and(|seg| seg.ident == "TestAppContext")
}
fn is_std_rng(arg: &FnArg) -> bool {
is_path_with_last_segment(arg, "StdRng")
}
fn is_background_executor(arg: &FnArg) -> bool {
is_path_with_last_segment(arg, "BackgroundExecutor")
}
fn is_path_with_last_segment(arg: &FnArg, last_segment: &str) -> bool {
let FnArg::Typed(arg) = arg else {
return false;
};
let Type::Path(ty) = &*arg.ty else {
return false;
};
ty.path
.segments
.last()
.is_some_and(|seg| seg.ident == last_segment)
}

View file

@ -19,11 +19,17 @@ rayon.workspace = true
log.workspace = true
ztracing.workspace = true
tracing.workspace = true
proptest = { workspace = true, optional = true }
[dev-dependencies]
ctor.workspace = true
rand.workspace = true
proptest.workspace = true
zlog.workspace = true
[package.metadata.cargo-machete]
ignored = ["tracing"]
[features]
test-support = ["proptest"]

View file

@ -0,0 +1,32 @@
use core::fmt::Debug;
use proptest::{prelude::*, sample::SizeRange};
use crate::{Item, SumTree, Summary};
impl<T> Arbitrary for SumTree<T>
where
T: Debug + Arbitrary + Item + 'static,
T::Summary: Debug + Summary<Context<'static> = ()>,
{
type Parameters = ();
type Strategy = BoxedStrategy<Self>;
fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
any::<Vec<T>>()
.prop_map(|vec| SumTree::from_iter(vec, ()))
.boxed()
}
}
/// A strategy for producing a [`SumTree`] with a given size.
///
/// Equivalent to [`proptest::collection::vec`].
pub fn sum_tree<S, T>(values: S, size: impl Into<SizeRange>) -> impl Strategy<Value = SumTree<T>>
where
T: Debug + Arbitrary + Item + 'static,
T::Summary: Debug + Summary<Context<'static> = ()>,
S: Strategy<Value = T>,
{
proptest::collection::vec(values, size).prop_map(|vec| SumTree::from_iter(vec, ()))
}

View file

@ -1,4 +1,6 @@
mod cursor;
#[cfg(any(test, feature = "test-support"))]
pub mod property_test;
mod tree_map;
use arrayvec::ArrayVec;

View file

@ -37,3 +37,4 @@ rand.workspace = true
util = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
zlog.workspace = true
proptest.workspace = true