Паника — не приговор: зачем тестировать аварийные ситуации в Rust?
Представьте: вы пишете на Rust библиотеку для работы с деньгами. Функция divide_money(amount: u64, parts: u64) должна паниковать, если parts равно нулю — ведь делить на ноль нельзя, а возвращать Option или Result вы не захотели по архитектурным причинам. Как убедиться, что эта паника действительно происходит? Или вы разрабатываете парсер, который должен аварийно завершаться при передаче некорректных данных. Без тестов на панику вы рискуете получить "тихие" баги, которые проявятся только в продакшне.
В Rust тестирование паники — это не просто "приятная опция", а важный инструмент для создания надёжного кода. В этой статье мы разберём, как писать unit-тесты, которые проверяют, что ваш код действительно паникует в нужных ситуациях. Вы узнаете о макросе #[should_panic], его нюансах, альтернативах вроде std::panic::catch_unwind и лучших практиках. Поехали!
Классический подход: макрос #[should_panic]
Самый простой и распространённый способ проверить панику в Rust — использовать атрибут #[should_panic]. Он ставится над тестовой функцией и говорит компилятору: "Этот тест считается успешным только если внутри произошла паника".
Базовый пример
// lib.rs или tests/unit_tests.rs
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("Деление на ноль!");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn test_divide_by_zero() {
divide(10, 0);
}
}
Запустите тесты командой cargo test — и вы увидите зелёный результат. Если паники не произойдёт (например, вы уберёте проверку на ноль), тест упадёт с сообщением: "test test_divide_by_zero ... FAILED (expected to panic)".
Проверка точного сообщения паники
Иногда нужно убедиться не просто в факте паники, а в её конкретном сообщении. Это помогает избежать ложных срабатываний, когда паника происходит из-за другой ошибки. Используйте дополнительный параметр expected:
#[test]
#[should_panic(expected = "Деление на ноль!")]
fn test_divide_by_zero_with_message() {
divide(10, 0);
}
// А этот тест не пройдёт — сообщение другое
#[test]
#[should_panic(expected = "Деление на ноль!")]
fn test_other_panic() {
panic!("Совсем другая ошибка");
}
Важно: expected проверяет, что сообщение паники содержит указанную строку (а не равно ей в точности). Это удобно для длинных сообщений.
Продвинутый подход: std::panic::catch_unwind
Иногда #[should_panic] недостаточно. Например, если вы хотите проверить, что паника произошла в конкретном месте, а не где-то ещё, или если нужно протестировать асинхронный код. Для таких случаев существует std::panic::catch_unwind — функция, которая перехватывает панику и возвращает её как Result.
Как это работает
use std::panic;
fn risky_function(should_panic: bool) {
if should_panic {
panic!("Авария!");
}
}
#[test]
fn test_catch_panic() {
let result = panic::catch_unwind(|| {
risky_function(true);
});
assert!(result.is_err()); // Проверяем, что была паника
}
#[test]
fn test_no_panic() {
let result = panic::catch_unwind(|| {
risky_function(false);
});
assert!(result.is_ok()); // Паники не было
}
Проверка типа паники
С catch_unwind можно даже проверить, что паника содержит определённое значение (не только строку):
use std::panic;
use std::any::Any;
fn panic_with_number() {
panic!(42); // Да, так можно — паника с числом!
}
#[test]
fn test_panic_with_number() {
let result = panic::catch_unwind(|| {
panic_with_number();
});
let panic_payload = result.unwrap_err();
let number = panic_payload.downcast_ref::<i32>();
assert_eq!(number, Some(&42));
}
Обратите внимание: catch_unwind не ловит все паники. Если паника вызвана через panic!("str") — ловится. А вот если это abort или паника в другом потоке (например, в std::thread::spawn), то catch_unwind не поможет.
Тестирование паники в асинхронном коде
Асинхронные тесты в Rust — отдельная история. Макрос #[should_panic] работает только с синхронными функциями. Для асинхронных тестов (например, с tokio::test) нужно комбинировать catch_unwind с блокирующим вызовом:
use tokio;
async fn async_risky() {
panic!("Асинхронная паника!");
}
#[tokio::test]
async fn test_async_panic() {
let result = std::panic::catch_unwind(|| {
// Блокируем текущий поток, чтобы дождаться асинхронной операции
tokio::runtime::Handle::current().block_on(async_risky());
});
assert!(result.is_err());
}
Но будьте осторожны: block_on внутри асинхронного контекста может вызвать deadlock. Для простых случаев проще вынести паникующий код в синхронную обёртку.
Лучшие практики и подводные камни
Когда использовать #[should_panic] vs catch_unwind?
- #[should_panic] — для 90% случаев. Просто, читаемо, не требует дополнительного кода.
- catch_unwind — когда нужно:
- Проверить наличие паники в одной части теста, но разрешить ей не быть в других.
- Извлечь payload паники (число, структуру).
- Тестировать асинхронный код (с осторожностью).
- Выполнить очистку после паники (например, закрыть файл).
Не злоупотребляйте паникой в production-коде
Помните: паника в Rust — это механизм для неисправимых ошибок. Для обычных ошибок используйте Result и Option. Тесты на панику — это скорее страховка для случаев, когда паника действительно оправдана (например, в тестовых утилитах или при проверке инвариантов).
Тестируйте только свои паники
Если вы используете стороннюю библиотеку, которая паникует при определённых условиях, не пишите тест на это — вы тестиру