Skip to content

Commit fcfc13a

Browse files
authored
Add support for #[should_panic] macro (#151)
This is done by recursively parsing attributes following the `#[libtest2::test]` macro, and then parsing the first `#[should_panic]` macro encountered. If a macro is encountered then the test will be run within a `catch_unwind`, which is done by wrapping it in the `assert_panic` macro (function style). Potentially supporting multiple `#[should_panic]` macros is left for the future. Fixes #15
2 parents e08a5b9 + 23e0c85 commit fcfc13a

File tree

5 files changed

+397
-19
lines changed

5 files changed

+397
-19
lines changed

crates/libtest2/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
//! - `#[test]` does not support all `Termination` types as return values,
3939
//! only what [`IntoRunResult`] supports.
4040
//! - `#[ignore]` must come after the `#[test]` macro
41+
//! - `#[should_ignore]` must come after the `#[test]` macro.
42+
//! The error output if the test fails to panic is also different from `libtest`.
4143
//! - Output capture and `--no-capture`: simply not supported. The official
4244
//! `libtest` uses internal `std` functions to temporarily redirect output.
4345
//! `libtest` cannot use those, see also [libtest2#12](https://github.com/assert-rs/libtest2/issues/12)
@@ -51,6 +53,8 @@
5153
mod case;
5254
mod macros;
5355

56+
pub mod panic;
57+
5458
#[doc(hidden)]
5559
pub mod _private {
5660
pub use distributed_list::push;
@@ -61,6 +65,7 @@ pub mod _private {
6165

6266
pub use crate::_main_parse as main_parse;
6367
pub use crate::_parse_ignore as parse_ignore;
68+
pub use crate::_run_test as run_test;
6469
pub use crate::_test_parse as test_parse;
6570
pub use crate::case::DynCase;
6671
}

crates/libtest2/src/macros.rs

Lines changed: 108 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,92 @@ macro_rules! _main_parse {
1414
}
1515

1616
#[macro_export]
17-
macro_rules! _parse_ignore {
18-
(ignore) => {
19-
::std::option::Option::<&'static str>::None
17+
#[allow(clippy::crate_in_macro_def)] // accessing item defined by `_main_parse`/`_parse_ignore`/`_run_test`, and recursively calling the macro itself
18+
macro_rules! _test_parse {
19+
// Entry point
20+
(#[test] $(#[$($attr:tt)+])* fn $name:ident $($item:tt)*) => {
21+
$crate::_private::test_parse!(continue:
22+
name=$name
23+
body=[$($item)*]
24+
attrs=[$(#[$($attr)+])*]
25+
);
26+
};
27+
28+
// Recursively handle attributes:
29+
30+
// Edge condition (no more attributes to parse)
31+
(continue: name=$name:ident body=[$($item:tt)*] attrs=[] $(ignore=$ignore:tt)? $(should_panic=$should_panic:tt)?) => {
32+
$crate::_private::test_parse!(break:
33+
name=$name
34+
body=[$($item)*]
35+
$(ignore=$ignore)?
36+
$(should_panic=$should_panic)?
37+
);
2038
};
21-
(ignore = $reason:expr) => {
22-
::std::option::Option::<&'static str>::Some($reason)
39+
// Process `#[ignore]`/`#[ignore = ".."]` (NOTE: This will only match if an ignore macro has not already been parsed)
40+
(continue: name=$name:ident body=[$($item:tt)*] attrs=[#[ignore $(= $reason:literal)?] $(#[$($attr:tt)+])*] $(should_panic=$should_panic:tt)?) => {
41+
$crate::_private::test_parse!(continue:
42+
name=$name
43+
body=[$($item)*]
44+
attrs=[$(#[$($attr)*])*]
45+
ignore=[$($reason)?]
46+
$(should_panic=$should_panic)?
47+
);
2348
};
24-
($($attr:tt)*) => {
25-
compile_error!(concat!("unknown attribute '", stringify!($($attr)*), "'"));
49+
// Ignore subsequent calls to `#[ignore]`/`#[ignore = ".."]`
50+
(continue: name=$name:ident body=[$($item:tt)*] attrs=[#[ignore $(= $reason:literal)?] $(#[$($attr:tt)+])*] ignore=$ignore:tt) => {
51+
$crate::_private::test_parse!(continue:
52+
name=$name
53+
body=[$($item)*]
54+
attrs=[$(#[$($attr)*])*]
55+
ignore=$ignore
56+
);
57+
};
58+
// Process `#[should_panic]`/`#[should_panic = ".."]` (NOTE: This will only match if a should_panic macro has not already been parsed)
59+
(continue: name=$name:ident body=[$($item:tt)*] attrs=[#[should_panic $(= $expected:literal)?] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)?) => {
60+
$crate::_private::test_parse!(continue:
61+
name=$name
62+
body=[$($item)*]
63+
attrs=[$(#[$($attr)*])*]
64+
$(ignore=$ignore)?
65+
should_panic=[$($expected)?]
66+
);
67+
};
68+
// Process `#[should_panic(expected = "..")]` (NOTE: Same as branch above)
69+
(continue: name=$name:ident body=[$($item:tt)*] attrs=[#[should_panic(expected = $expected:literal)] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)?) => {
70+
$crate::_private::test_parse!(continue:
71+
name=$name
72+
body=[$($item)*]
73+
attrs=[$(#[$($attr)*])*]
74+
$(ignore=$ignore)?
75+
should_panic=[$expected]
76+
);
77+
};
78+
// Emit an error for subsequent calls to `#[should_panic]`/`#[should_panic = ".."]`/`#[should_panic(expected = "..")]` (but continue parsing)
79+
(continue: name=$name:ident body=[$($item:tt)*] attrs=[#[should_panic $($unused:tt)*] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)? should_panic=$should_panic:tt) => {
80+
compile_error!("annotating a test with multiple 'should_panic' attributes is not allowed");
81+
$crate::_private::test_parse!(continue:
82+
name=$name
83+
body=[$($item)*]
84+
attrs=[$(#[$($attr)*])*]
85+
$(ignore=$ignore)?
86+
should_panic=$should_panic
87+
);
88+
};
89+
// Emit error on unknown attributes (but continue parsing)
90+
(continue: name=$name:ident body=[$($item:tt)*] attrs=[#[$($unknown_attr:tt)+] $(#[$($attr:tt)+])*] $(ignore=$ignore:tt)? $(should_panic=$should_panic:tt)?) => {
91+
compile_error!(concat!("unknown attribute '", stringify!($($unknown_attr)+), "'"));
92+
$crate::_private::test_parse!(continue:
93+
name=$name
94+
body=[$($item)*]
95+
attrs=[$(#[$($attr)*])*]
96+
$(ignore=$ignore)?
97+
$(should_panic=$should_panic)?
98+
);
2699
};
27-
}
28100

29-
#[macro_export]
30-
#[allow(clippy::crate_in_macro_def)] // accessing item defined by `_main_parse`
31-
macro_rules! _test_parse {
32-
(#[test] $(#[$($attr:tt)*])* fn $name:ident $($item:tt)*) => {
101+
// End result
102+
(break: name=$name:ident body=[$($item:tt)*] $(ignore=$ignore:tt)? $(should_panic=$should_panic:tt)?) => {
33103
#[allow(non_camel_case_types)]
34104
struct $name;
35105

@@ -52,17 +122,36 @@ macro_rules! _test_parse {
52122
fn run(&self, context: &$crate::TestContext) -> $crate::RunResult {
53123
fn run $($item)*
54124

55-
$(
56-
match $crate::_private::parse_ignore!($($attr)*) {
57-
::std::option::Option::None => context.ignore()?,
58-
::std::option::Option::Some(reason) => context.ignore_for(reason)?,
59-
}
60-
)*
125+
$crate::_private::parse_ignore!(context, $($ignore)?);
61126

62127
use $crate::IntoRunResult;
63-
let result = run(context);
128+
let result = $crate::_private::run_test!(context, $($should_panic)?);
64129
IntoRunResult::into_run_result(result)
65130
}
66131
}
67132
};
68133
}
134+
135+
#[macro_export]
136+
macro_rules! _parse_ignore {
137+
($context:expr, [$reason:literal] $(,)?) => {
138+
$context.ignore_for($reason)?
139+
};
140+
($context:expr, [] $(,)?) => {
141+
$context.ignore()?
142+
};
143+
($context:expr $(,)?) => {};
144+
}
145+
146+
#[macro_export]
147+
macro_rules! _run_test {
148+
($context:expr, [$expected:literal]) => {
149+
$crate::panic::assert_panic_contains(|| run($context), $expected)
150+
};
151+
($context:expr, []) => {
152+
$crate::panic::assert_panic(|| run($context))
153+
};
154+
($context:expr $(,)?) => {{
155+
run($context)
156+
}};
157+
}

crates/libtest2/src/panic.rs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//! This module contains functionality related to handling panics
2+
3+
use std::borrow::Cow;
4+
5+
const DID_NOT_PANIC: &str = "test did not panic as expected";
6+
7+
/// Error returned by [`assert_panic`] and [`assert_panic_contains`]
8+
#[derive(Debug)]
9+
pub struct AssertPanicError(Cow<'static, str>);
10+
11+
impl std::fmt::Display for AssertPanicError {
12+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13+
std::fmt::Display::fmt(&self.0, f)
14+
}
15+
}
16+
17+
impl std::error::Error for AssertPanicError {}
18+
19+
/// Assert that a piece of code is intended to panic
20+
///
21+
/// This will wrap the provided closure and check the result for a panic. If the function fails to panic
22+
/// an error value is returned, otherwise `Ok(())` is returned.
23+
///
24+
/// ```rust
25+
/// # use libtest2::panic::assert_panic;
26+
/// fn panicky_test() {
27+
/// panic!("intentionally fails");
28+
/// }
29+
///
30+
/// let result = assert_panic(panicky_test);
31+
/// assert!(result.is_ok());
32+
/// ```
33+
///
34+
/// If you also want to check that the panic contains a specific message see [`assert_panic_contains`].
35+
///
36+
/// # Notes
37+
/// This function will wrap the provided closure with a call to [`catch_unwind`](`std::panic::catch_unwind`),
38+
/// and will therefore inherit the caveats of this function, most notably that it will be unable to catch
39+
/// panics if they are not implemented via unwinding.
40+
pub fn assert_panic<T, F: FnOnce() -> T>(f: F) -> Result<(), AssertPanicError> {
41+
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
42+
// The test should have panicked, but didn't.
43+
Ok(_) => Err(AssertPanicError(Cow::Borrowed(DID_NOT_PANIC))),
44+
45+
// The test panicked, as expected.
46+
Err(_) => Ok(()),
47+
}
48+
}
49+
50+
/// Assert that a piece of code is intended to panic with a specific message
51+
///
52+
/// This will wrap the provided closure and check the result for a panic. If the function fails to panic with
53+
/// a message that contains the expected string an error value is returned, otherwise `Ok(())` is returned.
54+
///
55+
/// ```rust
56+
/// # use libtest2::panic::assert_panic_contains;
57+
/// fn panicky_test() {
58+
/// panic!("intentionally fails");
59+
/// }
60+
///
61+
/// let result = assert_panic_contains(panicky_test, "fail");
62+
/// assert!(result.is_ok());
63+
///
64+
/// let result = assert_panic_contains(panicky_test, "can't find this");
65+
/// assert!(result.is_err());
66+
/// ```
67+
///
68+
/// If you don't want to check that the panic contains a specific message see [`assert_panic`].
69+
///
70+
/// # Notes
71+
/// This function will wrap the provided closure with a call to [`catch_unwind`](`std::panic::catch_unwind`),
72+
/// and will therefore inherit the caveats of this function, most notably that it will be unable to catch
73+
/// panics if they are not implemented via unwinding.
74+
pub fn assert_panic_contains<T, F: FnOnce() -> T>(
75+
f: F,
76+
expected: &str,
77+
) -> Result<(), AssertPanicError> {
78+
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
79+
// The test should have panicked, but didn't.
80+
Ok(_) => Err(AssertPanicError(Cow::Borrowed(DID_NOT_PANIC))),
81+
82+
// The test panicked, as expected, but we need to check the panic message
83+
Err(payload) => check_panic_message(&*payload, expected),
84+
}
85+
}
86+
87+
#[cold]
88+
fn check_panic_message(
89+
payload: &dyn std::any::Any,
90+
expected: &str,
91+
) -> Result<(), AssertPanicError> {
92+
// The `panic` information is just an `Any` object representing the
93+
// value the panic was invoked with. For most panics (which use
94+
// `panic!` like `println!`), this is either `&str` or `String`.
95+
let maybe_panic_str = payload
96+
.downcast_ref::<String>()
97+
.map(|s| s.as_str())
98+
.or_else(|| payload.downcast_ref::<&str>().copied());
99+
100+
// Check the panic message against the expected message.
101+
match maybe_panic_str {
102+
Some(panic_str) if panic_str.contains(expected) => Ok(()),
103+
104+
Some(panic_str) => {
105+
let error_msg = ::std::format!(
106+
r#"panic did not contain expected string
107+
panic message: {panic_str:?}
108+
expected substring: {expected:?}"#
109+
);
110+
111+
Err(AssertPanicError(Cow::Owned(error_msg)))
112+
}
113+
114+
None => {
115+
let type_id = (*payload).type_id();
116+
let error_msg = ::std::format!(
117+
r#"expected panic with string value,
118+
found non-string value: `{type_id:?}`
119+
expected substring: {expected:?}"#,
120+
);
121+
122+
Err(AssertPanicError(Cow::Owned(error_msg)))
123+
}
124+
}
125+
}
126+
127+
#[cfg(test)]
128+
mod tests {
129+
use super::*;
130+
131+
#[test]
132+
fn assert_panic_with_panic() {
133+
let result = assert_panic(|| panic!("some message"));
134+
result.unwrap();
135+
}
136+
137+
#[test]
138+
fn assert_panic_no_panic() {
139+
let result = assert_panic(|| { /* do absolutely nothing */ });
140+
let error = result.unwrap_err();
141+
assert_eq!(error.to_string(), DID_NOT_PANIC);
142+
}
143+
144+
#[test]
145+
fn assert_panic_contains_correct_panic_message() {
146+
let result = assert_panic_contains(|| panic!("some message"), "mess");
147+
result.unwrap();
148+
}
149+
150+
#[test]
151+
fn assert_panic_contains_no_panic() {
152+
let result = assert_panic_contains(|| { /* do absolutely nothing */ }, "fail");
153+
let error = result.unwrap_err();
154+
assert_eq!(error.to_string(), DID_NOT_PANIC);
155+
}
156+
157+
#[test]
158+
fn assert_panic_contains_wrong_panic_message() {
159+
let result = assert_panic_contains(|| panic!("some message"), "fail");
160+
let error = result.unwrap_err();
161+
assert_eq!(
162+
error.0,
163+
r#"panic did not contain expected string
164+
panic message: "some message"
165+
expected substring: "fail""#
166+
);
167+
}
168+
}

crates/libtest2/tests/testsuite/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ mod all_passing;
22
mod argfile;
33
mod mixed_bag;
44
mod panic;
5+
mod should_panic;
56
mod util;
67

78
pub use util::*;

0 commit comments

Comments
 (0)