🦀 Rust

Тестирование кода в Rust: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты собрал сложный конструктор LEGO — космический корабль с двигателями, крыльями и кабиной пилота. Ты потратил несколько часов, всё скрепил, и теперь хочешь запустить его в «полёт». Но прежде чем отправить корабль на орбиту, ты проверяешь: не отвалится ли крыло, загорается ли свет в кабине, крутятся ли колёса шасси. Ты проводишь тестирование.

В программировании всё то же самое. Когда ты пишешь код на Rust, ты создаёшь программу, которая должна работать строго определённым образом. Тестирование — это процесс проверки того, что программа делает именно то, что ты задумал, и не делает ничего лишнего или опасного.

Зачем это нужно? Представь, что ты написал калькулятор, который должен складывать два числа. Без тестирования ты можешь случайно сделать так, что 2 + 2 будет равно 5, и узнаешь об этом только когда друг попросит посчитать деньги. С тестами ты проверяешь каждую кнопку заранее.

«Тестирование — это не наказание, а страховка. Оно защищает тебя от глупых ошибок, которые совершают даже самые опытные программисты».

В Rust тестирование встроено прямо в язык. Тебе не нужно устанавливать дополнительные программы — всё уже готово. Ты пишешь тесты рядом с кодом, и Rust сам проверяет, всё ли работает.

Теория: как это работает изнутри

Когда ты запускаешь тесты в Rust, происходит вот что:

  1. Компиляция с особыми настройками. Rust собирает твой код в специальном «тестовом» режиме. В этом режиме игнорируются все ошибки, связанные с производительностью, но зато включаются все проверки безопасности.
  2. Запуск каждой тестовой функции. Каждая функция, помеченная как тест, запускается отдельно. Если функция выполняется без ошибок — тест пройден. Если происходит «паника» (ошибка) — тест провален.
  3. Изоляция. Каждый тест работает в своём «песочнице». Если один тест упадёт, остальные продолжат работу. Rust не позволит одному сломанному тесту разрушить всю проверку.
  4. Сбор результатов. В конце ты видишь отчёт: сколько тестов прошло, сколько упало, и какие именно.

Внутри 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 иногда спотыкаются на тестах. Вот самые частые ошибки:

  1. Забыл атрибут #[test]. Функция есть, но она не помечена как тест. Rust просто проигнорирует её. Проверь, что перед каждой тестовой функцией стоит #[test].
  2. Тестовая функция принимает аргументы. Тесты не должны принимать параметры. Если тебе нужно передать данные — используй константы или функции-помощники внутри модуля.
  3. Путаница с assert_eq! и assert_ne!. assert_eq! проверяет равенство, assert_ne! — неравенство. Легко перепутать и написать assert_ne!(2+2, 4) — тест упадёт, потому что 4 равно 4.
  4. Забыл use super::*;. Внутри тестового модуля ты не видишь функции из родительского модуля, пока не импортируешь их. Без этой строки компилятор скажет: «Функция не найдена».
  5. Слишком много #[ignore]. Легко привыкнуть игнорировать сломанные тесты. Но если их станет слишком много, ты перестанешь замечать реальные проблемы. Старайся чинить тесты сразу.
  6. Тестирование приватных функций. В Rust ты можешь тестировать только публичные функции (с pub). Если функция приватная, тест из другого модуля её не увидит. Решение: либо делай функцию публичной, либо пиши тесты внутри того же модуля.
  7. Ошибки в документационных тестах. Если ты изменил функцию, но забыл обновить пример в документации — тест упадёт. Это хорошо! Не игнорируй такие ошибки, обновляй документацию.
«Самая опасная ошибка — это тест, который всегда проходит. Если тест не может упасть — он бесполезен».

Лучшие практики и советы профессионалов

Вот что советуют опытные разработчики на Rust:

  1. Пиши тесты до кода (TDD). Сначала напиши тест, который проверяет, что функция должна делать. Потом напиши саму функцию. Когда тест пройдёт — ты закончил. Это дисциплинирует и помогает не забыть про граничные случаи.
  2. Тестируй граничные случаи. Не проверяй только «нормальные» значения. Проверь: пустые строки, нули, отрицательные числа, очень большие числа. Именно там чаще всего прячутся ошибки.
  3. Один тест — одна проверка. Не пиши один огромный тест, который проверяет всё сразу. Лучше 10 маленьких тестов, каждый из которых проверяет одну вещь. Так проще понять, что именно сломалось.
  4. Используй понятные имена. Называй тесты так, чтобы было ясно, что они проверяют. тест_сложения_положительных_чисел лучше, чем тест1.
  5. Запускай тесты часто. Не жди, пока напишешь всю программу. Запускай cargo test после каждого маленького изменения. Так ты сразу узнаешь, что сломал.
  6. Не бойся #[should_panic]. Проверка ошибок так же важна, как проверка правильной работы. Если функция должна падать при определённых условиях — напиши тест на это.
  7. Документируй тестами. Документационные тесты — это не просто проверка, это живая документация. Читатель видит пример и знает, что он работает.
  8. Используй cargo test -- --nocapture. Если в тестах есть println!, ты их не увидишь при обычном запуске. Флаг --nocapture покажет весь вывод.

Вопросы для самопроверки

  1. Вопрос: Какой атрибут нужно поставить перед функцией, чтобы Rust понял, что это тест?

    Ответ: #[test]

  2. Вопрос: Чем отличается assert_eq! от assert_ne!?

    Ответ: assert_eq! проверяет, что два значения равны. assert_ne! проверяет, что они не равны.

  3. Вопрос: Что делает атрибут #[should_panic]?

    Ответ: Он говорит, что тест должен завершиться паникой. Если паники не происходит — тест считается проваленным.

  4. Вопрос: Зачем нужен модуль с атрибутом #[cfg(test)]?

    Ответ: Чтобы код тестов не включался в финальную сборку программы. Он компилируется только при запуске тестов.

  5. Вопрос: Что произойдёт, если в тестовом модуле забыть написать use super::*;?

    Ответ: Тест не увидит функции из основного кода. Компилятор выдаст ошибку «функция не найдена».

  6. Вопрос: Как временно отключить тест, не удаляя его?

    Ответ: Добавить атрибут #[ignore] перед функцией.

  7. Вопрос: Где хранятся интеграционные тесты?

    Ответ: В отдельной папке tests/ на том же уровне, что и папка src/.

  8. Вопрос: Что такое документационные тесты и чем они полезны?

    Ответ: Это примеры кода в документации (в тройных кавычках). Rust автоматически проверяет, что они работают. Это гарантирует, что документация не устаревает.

  9. Вопрос: Как запустить все тесты в проекте?

    Ответ: Командой cargo test в терминале.

  10. Вопрос: Почему не стоит писать один большой тест вместо нескольких маленьких?

    Ответ: Потому что если большой тест упадёт, будет непонятно, какая именно часть кода сломалась. Маленькие тесты указывают на конкретную проблему.

Итог и что изучить дальше

Тестирование в 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!