Как написать юнит-тест в Rust, который проверяет, что произошла паника (panic)?
Использование атрибута #[should_panic]
В Rust для проверки того, что функция вызывает панику, существует специальный атрибут #[should_panic]. Он указывается над тестовой функцией и заставляет тест считаться успешным только в том случае, если внутри него произошла паника. Если паники не было — тест проваливается.
Вот как это выглядит для вашего примера:
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("division by zero");
}
a / b
}
#[test]
#[should_panic]
fn test_divide_by_zero() {
divide(10, 0);
}
Теперь при запуске cargo test этот тест будет считаться успешным, потому что внутри него действительно происходит паника. Если бы вы передали divide(10, 2), тест бы провалился, так как паники бы не было.
Проверка сообщения паники
Часто бывает важно убедиться не просто в факте паники, но и в том, что она произошла с правильным сообщением. Для этого атрибут #[should_panic] поддерживает необязательный параметр expected. В него передаётся строка или подстрока, которая должна содержаться в сообщении паники.
#[test]
#[should_panic(expected = "division by zero")]
fn test_divide_by_zero_with_message() {
divide(10, 0);
}
Теперь тест проверит, что паника произошла и её сообщение содержит "division by zero". Если в будущем вы измените сообщение на "cannot divide by zero", тест провалится — это поможет вовремя заметить несоответствие.
Важно: проверка expected работает как поиск подстроки. То есть если сообщение паники — "Error: division by zero occurred", тест всё равно пройдёт, потому что искомая подстрока присутствует.
Подводные камни и лучшие практики
- Не злоупотребляйте паникой в production-коде. В Rust паника обычно означает неустранимую ошибку. Для ожидаемых ошибок лучше использовать
Result<T, E>и оператор?. Тесты с#[should_panic]оправданы только для ситуаций, где паника — это часть контракта функции (например, при переполнении буфера или нарушении инвариантов). - Точность проверки. Если вы проверяете только факт паники без
expected, то любая паника (даже от другой ошибки внутри функции) сделает тест успешным. Это может маскировать баги. Старайтесь всегда указывать ожидаемое сообщение. - Множественные паники. Если тест содержит несколько вызовов, которые могут паниковать, и вам нужно проверить только один из них, оберните остальные в отдельные блоки или используйте
std::panic::catch_unwind(см. альтернативный ответ). - Тестирование асинхронного кода. Атрибут
#[should_panic]работает и с асинхронными тестами (если вы используете#[tokio::test]или#[async_std::test]), но паника должна происходить внутри самого асинхронного блока.
Полный пример
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("division by zero");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "division by zero")]
fn test_divide_by_zero() {
divide(10, 0);
}
#[test]
fn test_divide_normal() {
assert_eq!(divide(10, 2), 5);
}
}
Запустите cargo test — первый тест пройдёт (паника перехвачена), второй — тоже (обычная проверка).
Альтернативный подход: std::panic::catch_unwind
Если вам нужен более тонкий контроль над проверкой паники (например, вы хотите проверить, что паника произошла в конкретном месте, а не во всём тесте, или вам нужно выполнить код после паники), используйте функцию std::panic::catch_unwind. Она перехватывает панику и возвращает Result.
use std::panic;
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("division by zero");
}
a / b
}
#[test]
fn test_divide_by_zero_catch() {
let result = panic::catch_unwind(|| {
divide(10, 0);
});
assert!(result.is_err()); // паника произошла
}
Вы также можете проверить сообщение паники, если используете result.unwrap_err().downcast_ref::<String>(). Это полезно, когда нужно выполнить несколько операций в одном тесте, и только одна из них должна паниковать.
#[test]
fn test_multiple_operations() {
// нормальная операция
assert_eq!(divide(10, 2), 5);
// проверяем панику
let result = panic::catch_unwind(|| divide(10, 0));
assert!(result.is_err());
// ещё одна нормальная операция
assert_eq!(divide(20, 4), 5);
}
Недостаток: catch_unwind не работает с паникой, вызванной через abort (если в Cargo.toml установлен panic = "abort"). В этом случае единственный вариант — #[should_panic].