Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/libtest2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -51,6 +53,8 @@
mod case;
mod macros;

pub mod panic;

#[doc(hidden)]
pub mod _private {
pub use distributed_list::push;
Expand All @@ -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;
}
Expand Down
127 changes: 108 additions & 19 deletions crates/libtest2/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)
}};
}
168 changes: 168 additions & 0 deletions crates/libtest2/src/panic.rs
Original file line number Diff line number Diff line change
@@ -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: FnOnce() -> 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: FnOnce() -> 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::<String>()
.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""#
);
}
}
1 change: 1 addition & 0 deletions crates/libtest2/tests/testsuite/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod all_passing;
mod argfile;
mod mixed_bag;
mod panic;
mod should_panic;
mod util;

pub use util::*;
Loading