diff --git a/crates/libtest2/src/lib.rs b/crates/libtest2/src/lib.rs index 900cae8..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) @@ -51,6 +53,8 @@ mod case; mod macros; +pub mod panic; + #[doc(hidden)] pub mod _private { pub use distributed_list::push; @@ -61,6 +65,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 aa3b852..f813d92 100644 --- a/crates/libtest2/src/macros.rs +++ b/crates/libtest2/src/macros.rs @@ -14,22 +14,92 @@ 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`/`_run_test`, 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)? $(should_panic=$should_panic:tt)?) => { + $crate::_private::test_parse!(break: + name=$name + body=[$($item)*] + $(ignore=$ignore)? + $(should_panic=$should_panic)? + ); }; - (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)+])*] $(should_panic=$should_panic:tt)?) => { + $crate::_private::test_parse!(continue: + name=$name + body=[$($item)*] + attrs=[$(#[$($attr)*])*] + ignore=[$($reason)?] + $(should_panic=$should_panic)? + ); }; - ($($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 + ); + }; + // 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)? $(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)? + ); }; -} -#[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)? $(should_panic=$should_panic:tt)?) => { #[allow(non_camel_case_types)] struct $name; @@ -52,17 +122,36 @@ 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); + let result = $crate::_private::run_test!(context, $($should_panic)?); IntoRunResult::into_run_result(result) } } }; } + +#[macro_export] +macro_rules! _parse_ignore { + ($context:expr, [$reason:literal] $(,)?) => { + $context.ignore_for($reason)? + }; + ($context:expr, [] $(,)?) => { + $context.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) + }}; +} 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""# + ); + } +} 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 + + +"#]], + ); +}