Тестирование кода в Rust: подробное руководство
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты собрал сложный конструктор LEGO — космический корабль с двигателями, крыльями и кабиной пилота. Ты потратил несколько часов, всё скрепил, и теперь хочешь запустить его в «полёт». Но прежде чем отправить корабль на орбиту, ты проверяешь: не отвалится ли крыло, загорается ли свет в кабине, крутятся ли колёса шасси. Ты проводишь тестирование.
В программировании всё то же самое. Когда ты пишешь код на Rust, ты создаёшь программу, которая должна работать строго определённым образом. Тестирование — это процесс проверки того, что программа делает именно то, что ты задумал, и не делает ничего лишнего или опасного.
Зачем это нужно? Представь, что ты написал калькулятор, который должен складывать два числа. Без тестирования ты можешь случайно сделать так, что 2 + 2 будет равно 5, и узнаешь об этом только когда друг попросит посчитать деньги. С тестами ты проверяешь каждую кнопку заранее.
«Тестирование — это не наказание, а страховка. Оно защищает тебя от глупых ошибок, которые совершают даже самые опытные программисты».
В Rust тестирование встроено прямо в язык. Тебе не нужно устанавливать дополнительные программы — всё уже готово. Ты пишешь тесты рядом с кодом, и Rust сам проверяет, всё ли работает.
Теория: как это работает изнутри
Когда ты запускаешь тесты в Rust, происходит вот что:
- Компиляция с особыми настройками. Rust собирает твой код в специальном «тестовом» режиме. В этом режиме игнорируются все ошибки, связанные с производительностью, но зато включаются все проверки безопасности.
- Запуск каждой тестовой функции. Каждая функция, помеченная как тест, запускается отдельно. Если функция выполняется без ошибок — тест пройден. Если происходит «паника» (ошибка) — тест провален.
- Изоляция. Каждый тест работает в своём «песочнице». Если один тест упадёт, остальные продолжат работу. Rust не позволит одному сломанному тесту разрушить всю проверку.
- Сбор результатов. В конце ты видишь отчёт: сколько тестов прошло, сколько упало, и какие именно.
Внутри Rust использует специальный атрибут #[test]. Когда компилятор видит этот атрибут, он понимает: «Ага, это не обычная функция, а тест. Её нужно запустить и проверить, не упадёт ли она».
Есть два основных вида тестов:
- Модульные тесты (unit tests) — проверяют маленькие кусочки кода: одну функцию, один метод. Они пишутся прямо в том же файле, где находится код.
- Интеграционные тесты (integration tests) — проверяют, как разные части программы работают вместе. Они пишутся в отдельной папке
tests/.
Rust также поддерживает документационные тесты — примеры кода в комментариях, которые автоматически проверяются. Если пример в документации устарел и не работает — компилятор скажет тебе об этом.
Синтаксис в Rust: все формы и варианты
Давай разберём все инструменты, которые даёт Rust для тестирования. Вот полная таблица:
| Инструмент | Синтаксис | Что делает |
|---|---|---|
| Атрибут теста | #[test] |
Помечает функцию как тест |
| Проверка на равенство | assert_eq!(a, b) |
Проверяет, что a равно b. Если нет — тест падает |
| Проверка на неравенство | assert_ne!(a, b) |
Проверяет, что a не равно b |
| Общая проверка | assert!(условие) |
Проверяет, что условие истинно |
| Ожидание паники | #[should_panic] |
Говорит: «Этот тест должен упасть с ошибкой». Если не упадёт — тест провален |
| Игнорирование теста | #[ignore] |
Временно отключает тест. Он не будет запускаться |
| Тестовый модуль | #[cfg(test)] |
Говорит: «Этот модуль нужен только для тестов, в обычную сборку не включай» |
Теперь разберём каждый инструмент подробнее.
Атрибут #[test] — это главный ключ. Ты ставишь его перед функцией, и Rust знает: эту функцию нужно запускать отдельно. Функция не должна принимать аргументы и не должна ничего возвращать (или возвращать ()).
Макросы assert!, assert_eq!, assert_ne! — это твои главные помощники. Они проверяют условия и, если что-то не так, останавливают тест и показывают сообщение об ошибке.
Атрибут #[should_panic] — хитрый инструмент. Иногда нужно проверить, что функция правильно обрабатывает ошибки. Например, если ты передаёшь отрицательное число в функцию, которая считает корень, она должна «упасть». #[should_panic] говорит: «Я ожидаю, что этот код вызовет панику. Если паники не будет — значит, тест провален».
Атрибут #[ignore] — для ленивых (но умных). Если ты знаешь, что тест сломан, но у тебя нет времени его чинить прямо сейчас, ты можешь его отключить. Он не будет запускаться, но и не будет мешать.
Модуль #[cfg(test)] — это контейнер для тестов. Ты оборачиваешь все свои тестовые функции в модуль с этим атрибутом. Rust поймёт: «Этот модуль нужен только для тестирования, в финальную программу его не включай». Это экономит место и время.
Примеры кода — от простого к сложному
Теперь перейдём к практике. Все примеры рабочие, ты можешь скопировать их и запустить.
Пример 1: Простейший тест
// Это наша основная функция, которая складывает два числа
fn сложить(a: i32, b: i32) -> i32 {
a + b // Возвращаем сумму
}
// Модуль для тестов. Он будет работать только когда мы запускаем тесты
#[cfg(test)]
mod тесты {
// Импортируем всё из родительского модуля (нашу функцию сложить)
use super::*;
// Помечаем функцию как тест
#[test]
fn тест_сложения() {
// Проверяем: 2 + 2 должно быть равно 4
assert_eq!(сложить(2, 2), 4);
// Если assert_eq! не упадёт — тест пройден
}
}
Объяснение: Мы создали функцию сложить, которая просто складывает два числа. Затем мы создали модуль тесты с атрибутом #[cfg(test)] — это значит, что этот модуль будет компилироваться только при запуске тестов. Внутри модуля мы написали тест тест_сложения с атрибутом #[test]. Внутри теста мы используем assert_eq!, чтобы проверить, что результат функции равен 4.
Пример 2: Проверка на неравенство
fn удвоить(x: i32) -> i32 {
x * 2
}
#[cfg(test)]
mod тесты {
use super::*;
#[test]
fn тест_удвоения() {
// Проверяем, что удвоенное 5 не равно 9
assert_ne!(удвоить(5), 9);
// 5 * 2 = 10, а 10 не равно 9 — тест пройден
}
}
Объяснение: Здесь мы используем assert_ne! (ne — not equal, не равно). Мы проверяем, что результат функции удвоить(5) не равен 9. Если бы функция вернула 9, тест бы упал.
Пример 3: Проверка на панику
fn разделить(a: i32, b: i32) -> i32 {
if b == 0 {
// Если делим на ноль — вызываем панику
panic!("На ноль делить нельзя!");
}
a / b
}
#[cfg(test)]
mod тесты {
use super::*;
// Этот тест должен упасть с ошибкой
#[test]
#[should_panic(expected = "На ноль делить нельзя!")]
fn тест_деления_на_ноль() {
разделить(10, 0); // Делим на ноль — должна быть паника
}
}
Объяснение: Функция разделить проверяет, не равен ли делитель нулю. Если равен — она вызывает панику с сообщением. Тест с атрибутом #[should_panic] ожидает эту панику. Если паника произойдёт — тест пройден. Если нет — тест провален. Параметр expected позволяет проверить, что сообщение об ошибке содержит нужный текст.
Пример 4: Игнорирование теста
fn сложная_функция() -> i32 {
// TODO: дописать логику
42 // Пока возвращаем временное значение
}
#[cfg(test)]
mod тесты {
use super::*;
// Этот тест пока не работает, мы его игнорируем
#[test]
#[ignore]
fn тест_сложной_функции() {
// Здесь должна быть сложная проверка
assert_eq!(сложная_функция(), 100);
// Этот тест не запустится, пока мы не уберём #[ignore]
}
// А этот тест работает
#[test]
fn простой_тест() {
assert_eq!(1 + 1, 2);
}
}
Объяснение: Иногда ты знаешь, что тест сломан, но не хочешь его удалять. Атрибут #[ignore] временно отключает тест. Он не будет запускаться, но останется в коде как напоминание. Когда ты допишешь функцию, просто убери #[ignore].
Пример 5: Тестирование структуры
// Создаём структуру для хранения информации о книге
struct Книга {
название: String,
автор: String,
страниц: u32,
}
impl Книга {
// Метод для создания новой книги
fn новая(название: &str, автор: &str, страниц: u32) -> Книга {
Книга {
название: название.to_string(),
автор: автор.to_string(),
страниц,
}
}
// Метод для проверки, толстая ли книга (больше 500 страниц)
fn толстая(&self) -> bool {
self.страниц > 500
}
}
#[cfg(test)]
mod тесты {
use super::*;
#[test]
fn тест_создания_книги() {
let книга = Книга::новая("Война и мир", "Толстой", 1225);
// Проверяем, что название правильное
assert_eq!(книга.название, "Война и мир");
// Проверяем, что автор правильный
assert_eq!(книга.автор, "Толстой");
// Проверяем количество страниц
assert_eq!(книга.страниц, 1225);
}
#[test]
fn тест_толстой_книги() {
let книга = Книга::новая("Война и мир", "Толстой", 1225);
// Проверяем, что книга толстая
assert!(книга.толстая());
}
#[test]
fn тест_тонкой_книги() {
let книга = Книга::новая("Колобок", "Народ", 10);
// Проверяем, что книга НЕ толстая
assert!(!книга.толстая());
}
}
Объяснение: Мы создали структуру Книга с тремя полями и двумя методами. Затем написали три теста: первый проверяет правильность создания книги, второй — что книга с 1225 страницами считается толстой, третий — что книга с 10 страницами не считается толстой. Обрати внимание на assert! — он проверяет, что условие истинно. assert!(!книга.толстая()) проверяет, что условие ложно (восклицательный знак — это отрицание).
Пример 6: Интеграционный тест
Создадим отдельный файл для интеграционных тестов. Для этого нужно создать папку tests рядом с src.
// Файл: tests/интеграционные_тесты.rs
// Импортируем нашу библиотеку (предположим, она называется "моя_библиотека")
use моя_библиотека;
#[test]
fn тест_интеграции() {
// Проверяем, что функция из библиотеки работает
let результат = моя_библиотека::сложить(10, 20);
assert_eq!(результат, 30);
}
Объяснение: Интеграционные тесты проверяют, как работает вся библиотека целиком. Они находятся в отдельной папке tests и видят только то, что библиотека «экспортирует» (публикует). Внутри теста мы вызываем функцию сложить из библиотеки и проверяем результат.
Пример 7: Документационный тест
/// Функция для вычисления площади прямоугольника
///
/// # Пример
///
/// ```
/// let площадь = моя_библиотека::площадь_прямоугольника(5, 10);
/// assert_eq!(площадь, 50);
/// ```
pub fn площадь_прямоугольника(ширина: u32, высота: u32) -> u32 {
ширина * высота
}
Объяснение: Это магия Rust. Код внутри тройных обратных кавычек в документации — это не просто пример, это тест! Когда ты запускаешь cargo test, Rust находит все такие примеры и проверяет, что они работают. Если пример устарел и не компилируется — ты узнаешь об этом сразу.
Сравнение с Python: чем похоже и чем отличается
Если ты уже знаком с Python, тебе будет легче понять Rust. Давай сравним:
| Характеристика | Rust | Python |
|---|---|---|
| Встроенные тесты | Да, прямо в язык | Нет, нужен модуль unittest или pytest |
| Запуск тестов | cargo test |
python -m unittest или pytest |
| Атрибуты | #[test], #[should_panic] |
Декораторы @unittest.skip, @pytest.mark.xfail |
| Проверки | assert_eq!, assert_ne!, assert! |
assertEqual, assertNotEqual, assertTrue |
| Изоляция тестов | Автоматическая, каждый тест в своей песочнице | Нужно создавать вручную (setUp/tearDown) |
| Документационные тесты | Встроены, проверяются автоматически | Нет встроенной поддержки (есть doctest, но он отдельно) |
| Типизация | Строгая, компилятор ловит ошибки типов до тестов | Динамическая, ошибки типов ловятся только во время выполнения |
Главное отличие: В Rust тесты — это часть языка. Ты не думаешь «надо бы написать тесты», ты просто их пишешь рядом с кодом. В Python тесты — это отдельная культура, которую нужно осваивать дополнительно.
Ещё важное отличие: Rust проверяет тесты на этапе компиляции. Если в тесте есть синтаксическая ошибка, программа даже не соберётся. В Python ты узнаешь об ошибке только когда запустишь тест.
Типичные ошибки и подводные камни
Даже опытные программисты на Rust иногда спотыкаются на тестах. Вот самые частые ошибки:
- Забыл атрибут
#[test]. Функция есть, но она не помечена как тест. Rust просто проигнорирует её. Проверь, что перед каждой тестовой функцией стоит#[test]. - Тестовая функция принимает аргументы. Тесты не должны принимать параметры. Если тебе нужно передать данные — используй константы или функции-помощники внутри модуля.
- Путаница с
assert_eq!иassert_ne!.assert_eq!проверяет равенство,assert_ne!— неравенство. Легко перепутать и написатьassert_ne!(2+2, 4)— тест упадёт, потому что 4 равно 4. - Забыл
use super::*;. Внутри тестового модуля ты не видишь функции из родительского модуля, пока не импортируешь их. Без этой строки компилятор скажет: «Функция не найдена». - Слишком много
#[ignore]. Легко привыкнуть игнорировать сломанные тесты. Но если их станет слишком много, ты перестанешь замечать реальные проблемы. Старайся чинить тесты сразу. - Тестирование приватных функций. В Rust ты можешь тестировать только публичные функции (с
pub). Если функция приватная, тест из другого модуля её не увидит. Решение: либо делай функцию публичной, либо пиши тесты внутри того же модуля. - Ошибки в документационных тестах. Если ты изменил функцию, но забыл обновить пример в документации — тест упадёт. Это хорошо! Не игнорируй такие ошибки, обновляй документацию.
«Самая опасная ошибка — это тест, который всегда проходит. Если тест не может упасть — он бесполезен».
Лучшие практики и советы профессионалов
Вот что советуют опытные разработчики на Rust:
- Пиши тесты до кода (TDD). Сначала напиши тест, который проверяет, что функция должна делать. Потом напиши саму функцию. Когда тест пройдёт — ты закончил. Это дисциплинирует и помогает не забыть про граничные случаи.
- Тестируй граничные случаи. Не проверяй только «нормальные» значения. Проверь: пустые строки, нули, отрицательные числа, очень большие числа. Именно там чаще всего прячутся ошибки.
- Один тест — одна проверка. Не пиши один огромный тест, который проверяет всё сразу. Лучше 10 маленьких тестов, каждый из которых проверяет одну вещь. Так проще понять, что именно сломалось.
- Используй понятные имена. Называй тесты так, чтобы было ясно, что они проверяют.
тест_сложения_положительных_чиселлучше, чемтест1. - Запускай тесты часто. Не жди, пока напишешь всю программу. Запускай
cargo testпосле каждого маленького изменения. Так ты сразу узнаешь, что сломал. - Не бойся
#[should_panic]. Проверка ошибок так же важна, как проверка правильной работы. Если функция должна падать при определённых условиях — напиши тест на это. - Документируй тестами. Документационные тесты — это не просто проверка, это живая документация. Читатель видит пример и знает, что он работает.
- Используй
cargo test -- --nocapture. Если в тестах естьprintln!, ты их не увидишь при обычном запуске. Флаг--nocaptureпокажет весь вывод.
Вопросы для самопроверки
-
Вопрос: Какой атрибут нужно поставить перед функцией, чтобы Rust понял, что это тест?
Ответ:
#[test] -
Вопрос: Чем отличается
assert_eq!отassert_ne!?Ответ:
assert_eq!проверяет, что два значения равны.assert_ne!проверяет, что они не равны. -
Вопрос: Что делает атрибут
#[should_panic]?Ответ: Он говорит, что тест должен завершиться паникой. Если паники не происходит — тест считается проваленным.
-
Вопрос: Зачем нужен модуль с атрибутом
#[cfg(test)]?Ответ: Чтобы код тестов не включался в финальную сборку программы. Он компилируется только при запуске тестов.
-
Вопрос: Что произойдёт, если в тестовом модуле забыть написать
use super::*;?Ответ: Тест не увидит функции из основного кода. Компилятор выдаст ошибку «функция не найдена».
-
Вопрос: Как временно отключить тест, не удаляя его?
Ответ: Добавить атрибут
#[ignore]перед функцией. -
Вопрос: Где хранятся интеграционные тесты?
Ответ: В отдельной папке
tests/на том же уровне, что и папкаsrc/. -
Вопрос: Что такое документационные тесты и чем они полезны?
Ответ: Это примеры кода в документации (в тройных кавычках). Rust автоматически проверяет, что они работают. Это гарантирует, что документация не устаревает.
-
Вопрос: Как запустить все тесты в проекте?
Ответ: Командой
cargo testв терминале. -
Вопрос: Почему не стоит писать один большой тест вместо нескольких маленьких?
Ответ: Потому что если большой тест упадёт, будет непонятно, какая именно часть кода сломалась. Маленькие тесты указывают на конкретную проблему.
Итог и что изучить дальше
Тестирование в Rust — это не скучная обязанность, а твой верный помощник. Оно встроено в язык, работает быстро и помогает писать надёжные программы. Ты научился:
- Писать модульные тесты с
#[test] - Проверять равенство и неравенство с
assert_eq!иassert_ne! - Проверять условия с
assert! - Ожидать ошибки с
#[should_panic] - Игнорировать временно сломанные тесты с
#[ignore] - Организовывать тесты в модули с
#[cfg(test)] - Писать интеграционные тесты в папке
tests/ - Создавать живую документацию с документационными тестами
Что изучить дальше:
- Моки и заглушки (mocking). Когда нужно протестировать функцию, которая обращается к базе данных или интернету, используют специальные «заглушки», которые имитируют внешние сервисы.
- Тестирование производительности (benchmarks). Rust позволяет измерять, сколько времени занимает выполнение функции. Это полезно, когда нужно убедиться, что код работает быстро.
- Property-based testing. Вместо того чтобы писать конкретные примеры, ты описываешь свойства, которым должна удовлетворять функция, и специальная библиотека (например,
proptest) генерирует тысячи случайных тестов. - Непрерывная интеграция (CI). Настройка автоматического запуска тестов при каждом изменении кода. Самые популярные сервисы: GitHub Actions, GitLab CI.
Помни: хороший программист — это не тот, кто не делает ошибок, а тот, кто умеет их находить и исправлять. Тесты — твой главный инструмент для этого. Удачи в изучении Rust!