From 7f83351d04b9a069fb0c242841cf86244d304744 Mon Sep 17 00:00:00 2001 From: Herman Skogseth Date: Sat, 1 Nov 2025 17:51:48 +0100 Subject: [PATCH 1/5] fix: Recursive parsing of attributes The `test_parse` macro is changed to recursively parse attributes, filtering out the `#[ignore]` macro. This should also make it fairly trivial to add additional macros, such as `#[should_ignore]`. There is a fair amount of complexity added to the macro with this change, but on the other hand, it also succeeds in generating only the necessary code. You win some, you lose some. --- crates/libtest2/src/macros.rs | 78 +++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/crates/libtest2/src/macros.rs b/crates/libtest2/src/macros.rs index aa3b852..e95aa4c 100644 --- a/crates/libtest2/src/macros.rs +++ b/crates/libtest2/src/macros.rs @@ -14,22 +14,58 @@ macro_rules! _main_parse { } #[macro_export] -macro_rules! _parse_ignore { - (ignore) => { - ::std::option::Option::<&'static str>::None +#[allow(clippy::crate_in_macro_def)] // accessing item defined by `_main_parse`/`_parse_ignore`, and recursively calling the macro itself +macro_rules! _test_parse { + // Entry point + (#[test] $(#[$($attr:tt)+])* fn $name:ident $($item:tt)*) => { + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)+])*] + ); + }; + + // Recursively handle attributes: + + // Edge condition (no more attributes to parse) + (continue: name=$name:ident body=[$($item:tt)*] attrs=[] $(ignore=$ignore:tt)?) => { + $crate::_private::test_parse!(break: + name=$name + body=[$($item)*] + $(ignore=$ignore)? + ); }; - (ignore = $reason:expr) => { - ::std::option::Option::<&'static str>::Some($reason) + // Process `#[ignore]`/`#[ignore = ".."]` (NOTE: This will only match if an ignore macro has not already been parsed) + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[ignore $(= $reason:literal)?] $(#[$($attr:tt)+])*]) => { + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)*])*] + ignore=[$($reason)?] + ); }; - ($($attr:tt)*) => { - compile_error!(concat!("unknown attribute '", stringify!($($attr)*), "'")); + // Ignore subsequent calls to `#[ignore]`/`#[ignore = ".."]` + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[ignore $(= $reason:literal)?] $(#[$($attr:tt)+])*] ignore=$ignore:tt) => { + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)*])*] + ignore=$ignore + ); + }; + // Emit error on unknown attributes (but continue parsing) + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[$($unknown_attr:tt)+] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)?) => { + compile_error!(concat!("unknown attribute '", stringify!($($unknown_attr)+), "'")); + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)*])*] + $(ignore=$ignore)? + ); }; -} -#[macro_export] -#[allow(clippy::crate_in_macro_def)] // accessing item defined by `_main_parse` -macro_rules! _test_parse { - (#[test] $(#[$($attr:tt)*])* fn $name:ident $($item:tt)*) => { + // End result + (break: name=$name:ident body=[$($item:tt)*] $(ignore=$ignore:tt)?) => { #[allow(non_camel_case_types)] struct $name; @@ -52,12 +88,7 @@ macro_rules! _test_parse { fn run(&self, context: &$crate::TestContext) -> $crate::RunResult { fn run $($item)* - $( - match $crate::_private::parse_ignore!($($attr)*) { - ::std::option::Option::None => context.ignore()?, - ::std::option::Option::Some(reason) => context.ignore_for(reason)?, - } - )* + $crate::_private::parse_ignore!(context, $($ignore)?); use $crate::IntoRunResult; let result = run(context); @@ -66,3 +97,14 @@ macro_rules! _test_parse { } }; } + +#[macro_export] +macro_rules! _parse_ignore { + ($context:expr, [$reason:literal] $(,)?) => { + $context.ignore_for($reason)? + }; + ($context:expr, [] $(,)?) => { + $context.ignore()? + }; + ($context:expr $(,)?) => {}; +} From 94e04344f9db6d23017f65f267d54cf5dec80dd8 Mon Sep 17 00:00:00 2001 From: Herman Skogseth Date: Fri, 24 Oct 2025 12:23:52 +0200 Subject: [PATCH 2/5] feat: Add functions `assert_panic` and `assert_panic_contains` --- crates/libtest2/src/lib.rs | 2 + crates/libtest2/src/panic.rs | 168 +++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 crates/libtest2/src/panic.rs diff --git a/crates/libtest2/src/lib.rs b/crates/libtest2/src/lib.rs index 900cae8..efe3551 100644 --- a/crates/libtest2/src/lib.rs +++ b/crates/libtest2/src/lib.rs @@ -51,6 +51,8 @@ mod case; mod macros; +pub mod panic; + #[doc(hidden)] pub mod _private { pub use distributed_list::push; diff --git a/crates/libtest2/src/panic.rs b/crates/libtest2/src/panic.rs new file mode 100644 index 0000000..b422198 --- /dev/null +++ b/crates/libtest2/src/panic.rs @@ -0,0 +1,168 @@ +//! This module contains functionality related to handling panics + +use std::borrow::Cow; + +const DID_NOT_PANIC: &str = "test did not panic as expected"; + +/// Error returned by [`assert_panic`] and [`assert_panic_contains`] +#[derive(Debug)] +pub struct AssertPanicError(Cow<'static, str>); + +impl std::fmt::Display for AssertPanicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for AssertPanicError {} + +/// Assert that a piece of code is intended to panic +/// +/// This will wrap the provided closure and check the result for a panic. If the function fails to panic +/// an error value is returned, otherwise `Ok(())` is returned. +/// +/// ```rust +/// # use libtest2::panic::assert_panic; +/// fn panicky_test() { +/// panic!("intentionally fails"); +/// } +/// +/// let result = assert_panic(panicky_test); +/// assert!(result.is_ok()); +/// ``` +/// +/// If you also want to check that the panic contains a specific message see [`assert_panic_contains`]. +/// +/// # Notes +/// This function will wrap the provided closure with a call to [`catch_unwind`](`std::panic::catch_unwind`), +/// and will therefore inherit the caveats of this function, most notably that it will be unable to catch +/// panics if they are not implemented via unwinding. +pub fn assert_panic T>(f: F) -> Result<(), AssertPanicError> { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + // The test should have panicked, but didn't. + Ok(_) => Err(AssertPanicError(Cow::Borrowed(DID_NOT_PANIC))), + + // The test panicked, as expected. + Err(_) => Ok(()), + } +} + +/// Assert that a piece of code is intended to panic with a specific message +/// +/// This will wrap the provided closure and check the result for a panic. If the function fails to panic with +/// a message that contains the expected string an error value is returned, otherwise `Ok(())` is returned. +/// +/// ```rust +/// # use libtest2::panic::assert_panic_contains; +/// fn panicky_test() { +/// panic!("intentionally fails"); +/// } +/// +/// let result = assert_panic_contains(panicky_test, "fail"); +/// assert!(result.is_ok()); +/// +/// let result = assert_panic_contains(panicky_test, "can't find this"); +/// assert!(result.is_err()); +/// ``` +/// +/// If you don't want to check that the panic contains a specific message see [`assert_panic`]. +/// +/// # Notes +/// This function will wrap the provided closure with a call to [`catch_unwind`](`std::panic::catch_unwind`), +/// and will therefore inherit the caveats of this function, most notably that it will be unable to catch +/// panics if they are not implemented via unwinding. +pub fn assert_panic_contains T>( + f: F, + expected: &str, +) -> Result<(), AssertPanicError> { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + // The test should have panicked, but didn't. + Ok(_) => Err(AssertPanicError(Cow::Borrowed(DID_NOT_PANIC))), + + // The test panicked, as expected, but we need to check the panic message + Err(payload) => check_panic_message(&*payload, expected), + } +} + +#[cold] +fn check_panic_message( + payload: &dyn std::any::Any, + expected: &str, +) -> Result<(), AssertPanicError> { + // The `panic` information is just an `Any` object representing the + // value the panic was invoked with. For most panics (which use + // `panic!` like `println!`), this is either `&str` or `String`. + let maybe_panic_str = payload + .downcast_ref::() + .map(|s| s.as_str()) + .or_else(|| payload.downcast_ref::<&str>().copied()); + + // Check the panic message against the expected message. + match maybe_panic_str { + Some(panic_str) if panic_str.contains(expected) => Ok(()), + + Some(panic_str) => { + let error_msg = ::std::format!( + r#"panic did not contain expected string + panic message: {panic_str:?} + expected substring: {expected:?}"# + ); + + Err(AssertPanicError(Cow::Owned(error_msg))) + } + + None => { + let type_id = (*payload).type_id(); + let error_msg = ::std::format!( + r#"expected panic with string value, + found non-string value: `{type_id:?}` + expected substring: {expected:?}"#, + ); + + Err(AssertPanicError(Cow::Owned(error_msg))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn assert_panic_with_panic() { + let result = assert_panic(|| panic!("some message")); + result.unwrap(); + } + + #[test] + fn assert_panic_no_panic() { + let result = assert_panic(|| { /* do absolutely nothing */ }); + let error = result.unwrap_err(); + assert_eq!(error.to_string(), DID_NOT_PANIC); + } + + #[test] + fn assert_panic_contains_correct_panic_message() { + let result = assert_panic_contains(|| panic!("some message"), "mess"); + result.unwrap(); + } + + #[test] + fn assert_panic_contains_no_panic() { + let result = assert_panic_contains(|| { /* do absolutely nothing */ }, "fail"); + let error = result.unwrap_err(); + assert_eq!(error.to_string(), DID_NOT_PANIC); + } + + #[test] + fn assert_panic_contains_wrong_panic_message() { + let result = assert_panic_contains(|| panic!("some message"), "fail"); + let error = result.unwrap_err(); + assert_eq!( + error.0, + r#"panic did not contain expected string + panic message: "some message" + expected substring: "fail""# + ); + } +} From 1dae3b51b15e03c0fe8195d953d254e80c90d79b Mon Sep 17 00:00:00 2001 From: Herman Skogseth Date: Fri, 24 Oct 2025 12:23:52 +0200 Subject: [PATCH 3/5] feat: Add support for '#[should_panic]' macro --- crates/libtest2/src/lib.rs | 1 + crates/libtest2/src/macros.rs | 59 +++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/libtest2/src/lib.rs b/crates/libtest2/src/lib.rs index efe3551..a2b59fa 100644 --- a/crates/libtest2/src/lib.rs +++ b/crates/libtest2/src/lib.rs @@ -63,6 +63,7 @@ pub mod _private { pub use crate::_main_parse as main_parse; pub use crate::_parse_ignore as parse_ignore; + pub use crate::_run_test as run_test; pub use crate::_test_parse as test_parse; pub use crate::case::DynCase; } diff --git a/crates/libtest2/src/macros.rs b/crates/libtest2/src/macros.rs index e95aa4c..f813d92 100644 --- a/crates/libtest2/src/macros.rs +++ b/crates/libtest2/src/macros.rs @@ -14,7 +14,7 @@ macro_rules! _main_parse { } #[macro_export] -#[allow(clippy::crate_in_macro_def)] // accessing item defined by `_main_parse`/`_parse_ignore`, and recursively calling the macro itself +#[allow(clippy::crate_in_macro_def)] // accessing item defined by `_main_parse`/`_parse_ignore`/`_run_test`, and recursively calling the macro itself macro_rules! _test_parse { // Entry point (#[test] $(#[$($attr:tt)+])* fn $name:ident $($item:tt)*) => { @@ -28,20 +28,22 @@ macro_rules! _test_parse { // Recursively handle attributes: // Edge condition (no more attributes to parse) - (continue: name=$name:ident body=[$($item:tt)*] attrs=[] $(ignore=$ignore:tt)?) => { + (continue: name=$name:ident body=[$($item:tt)*] attrs=[] $(ignore=$ignore:tt)? $(should_panic=$should_panic:tt)?) => { $crate::_private::test_parse!(break: name=$name body=[$($item)*] $(ignore=$ignore)? + $(should_panic=$should_panic)? ); }; // Process `#[ignore]`/`#[ignore = ".."]` (NOTE: This will only match if an ignore macro has not already been parsed) - (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[ignore $(= $reason:literal)?] $(#[$($attr:tt)+])*]) => { + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[ignore $(= $reason:literal)?] $(#[$($attr:tt)+])*] $(should_panic=$should_panic:tt)?) => { $crate::_private::test_parse!(continue: name=$name body=[$($item)*] attrs=[$(#[$($attr)*])*] ignore=[$($reason)?] + $(should_panic=$should_panic)? ); }; // Ignore subsequent calls to `#[ignore]`/`#[ignore = ".."]` @@ -53,19 +55,51 @@ macro_rules! _test_parse { ignore=$ignore ); }; + // Process `#[should_panic]`/`#[should_panic = ".."]` (NOTE: This will only match if a should_panic macro has not already been parsed) + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[should_panic $(= $expected:literal)?] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)?) => { + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)*])*] + $(ignore=$ignore)? + should_panic=[$($expected)?] + ); + }; + // Process `#[should_panic(expected = "..")]` (NOTE: Same as branch above) + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[should_panic(expected = $expected:literal)] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)?) => { + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)*])*] + $(ignore=$ignore)? + should_panic=[$expected] + ); + }; + // Emit an error for subsequent calls to `#[should_panic]`/`#[should_panic = ".."]`/`#[should_panic(expected = "..")]` (but continue parsing) + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[should_panic $($unused:tt)*] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)? should_panic=$should_panic:tt) => { + compile_error!("annotating a test with multiple 'should_panic' attributes is not allowed"); + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)*])*] + $(ignore=$ignore)? + should_panic=$should_panic + ); + }; // Emit error on unknown attributes (but continue parsing) - (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[$($unknown_attr:tt)+] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)?) => { + (continue: name=$name:ident body=[$($item:tt)*] attrs=[#[$($unknown_attr:tt)+] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)? $(should_panic=$should_panic:tt)?) => { compile_error!(concat!("unknown attribute '", stringify!($($unknown_attr)+), "'")); $crate::_private::test_parse!(continue: name=$name body=[$($item)*] attrs=[$(#[$($attr)*])*] $(ignore=$ignore)? + $(should_panic=$should_panic)? ); }; // End result - (break: name=$name:ident body=[$($item:tt)*] $(ignore=$ignore:tt)?) => { + (break: name=$name:ident body=[$($item:tt)*] $(ignore=$ignore:tt)? $(should_panic=$should_panic:tt)?) => { #[allow(non_camel_case_types)] struct $name; @@ -91,7 +125,7 @@ macro_rules! _test_parse { $crate::_private::parse_ignore!(context, $($ignore)?); use $crate::IntoRunResult; - let result = run(context); + let result = $crate::_private::run_test!(context, $($should_panic)?); IntoRunResult::into_run_result(result) } } @@ -108,3 +142,16 @@ macro_rules! _parse_ignore { }; ($context:expr $(,)?) => {}; } + +#[macro_export] +macro_rules! _run_test { + ($context:expr, [$expected:literal]) => { + $crate::panic::assert_panic_contains(|| run($context), $expected) + }; + ($context:expr, []) => { + $crate::panic::assert_panic(|| run($context)) + }; + ($context:expr $(,)?) => {{ + run($context) + }}; +} From 2d7b1cc6aafaf3f63860cbe0048dad91b973eba8 Mon Sep 17 00:00:00 2001 From: Herman Skogseth Date: Fri, 31 Oct 2025 13:01:57 +0100 Subject: [PATCH 4/5] fix: Add tests for '#[should_panic]' macro --- crates/libtest2/tests/testsuite/main.rs | 1 + .../libtest2/tests/testsuite/should_panic.rs | 115 ++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 crates/libtest2/tests/testsuite/should_panic.rs diff --git a/crates/libtest2/tests/testsuite/main.rs b/crates/libtest2/tests/testsuite/main.rs index 66bad57..2d83e9d 100644 --- a/crates/libtest2/tests/testsuite/main.rs +++ b/crates/libtest2/tests/testsuite/main.rs @@ -2,6 +2,7 @@ mod all_passing; mod argfile; mod mixed_bag; mod panic; +mod should_panic; mod util; pub use util::*; diff --git a/crates/libtest2/tests/testsuite/should_panic.rs b/crates/libtest2/tests/testsuite/should_panic.rs new file mode 100644 index 0000000..c56b017 --- /dev/null +++ b/crates/libtest2/tests/testsuite/should_panic.rs @@ -0,0 +1,115 @@ +use snapbox::prelude::*; +use snapbox::str; + +fn test_cmd() -> snapbox::cmd::Command { + static BIN: once_cell_polyfill::sync::OnceLock<(std::path::PathBuf, std::path::PathBuf)> = + once_cell_polyfill::sync::OnceLock::new(); + let (bin, current_dir) = BIN.get_or_init(|| { + let package_root = crate::util::new_test( + r#" +#[libtest2::main] +fn main() {} + +#[libtest2::test] +fn accidentally_panics(_context: &libtest2::TestContext) { + panic!("uh oh") +} + +#[libtest2::test] +#[should_panic] +fn intentionally_panics(_context: &libtest2::TestContext) { + panic!("any message would do") +} + +#[libtest2::test] +#[should_panic = "intentional"] +fn intentionally_panics_with_message(_context: &libtest2::TestContext) { + panic!("this is intentional") +} + +#[libtest2::test] +#[should_panic = "in a controlled manner"] +fn panics_with_the_wrong_message(_context: &libtest2::TestContext) { + panic!("with the wrong message") +} +"#, + false, + ); + let bin = crate::util::compile_test(&package_root); + (bin, package_root) + }); + snapbox::cmd::Command::new(bin).current_dir(current_dir) +} + +fn check(args: &[&str], code: i32, single: impl IntoData, parallel: impl IntoData) { + test_cmd() + .args(args) + .args(["--test-threads", "1"]) + .assert() + .code(code) + .stdout_eq(single); + test_cmd() + .args(args) + .assert() + .code(code) + .stdout_eq(parallel); +} + +#[test] +fn normal() { + check( + &[], + 101, + str![[r#" + +running 4 tests +test accidentally_panics ... FAILED +test intentionally_panics ... ok +test intentionally_panics_with_message ... ok +test panics_with_the_wrong_message ... FAILED + +failures: + +---- accidentally_panics ---- +test panicked: uh oh + +---- panics_with_the_wrong_message ---- +panic did not contain expected string + panic message: "with the wrong message" + expected substring: "in a controlled manner" + + +failures: + accidentally_panics + panics_with_the_wrong_message + +test result: FAILED. 2 passed; 2 failed; 0 ignored; 0 filtered out; finished in [..]s + + +"#]], + str![[r#" + +running 4 tests +... + +failures: + +---- accidentally_panics ---- +test panicked: uh oh + +---- panics_with_the_wrong_message ---- +panic did not contain expected string + panic message: "with the wrong message" + expected substring: "in a controlled manner" + + +failures: + accidentally_panics + panics_with_the_wrong_message + +test result: FAILED. 2 passed; 2 failed; 0 ignored; 0 filtered out; finished in [..]s + + +"#]], + ); +} From 23e0c8507af580365240d4ff916b377375ec45d4 Mon Sep 17 00:00:00 2001 From: Herman Skogseth Date: Wed, 5 Nov 2025 10:31:37 +0100 Subject: [PATCH 5/5] docs: Document should_panic macro deviation --- crates/libtest2/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/libtest2/src/lib.rs b/crates/libtest2/src/lib.rs index a2b59fa..17634d2 100644 --- a/crates/libtest2/src/lib.rs +++ b/crates/libtest2/src/lib.rs @@ -38,6 +38,8 @@ //! - `#[test]` does not support all `Termination` types as return values, //! only what [`IntoRunResult`] supports. //! - `#[ignore]` must come after the `#[test]` macro +//! - `#[should_ignore]` must come after the `#[test]` macro. +//! The error output if the test fails to panic is also different from `libtest`. //! - Output capture and `--no-capture`: simply not supported. The official //! `libtest` uses internal `std` functions to temporarily redirect output. //! `libtest` cannot use those, see also [libtest2#12](https://github.com/assert-rs/libtest2/issues/12)