TDD в Rust: разработка через тесты для надёжного кода

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему TDD в Rust — это не скучно, а выгодно?

Вы когда-нибудь писали код, который работал «вроде бы правильно», а потом ловили баг в самом неожиданном месте? Знакомо. Разработка через тестирование (TDD) — это метод, который переворачивает привычный процесс с ног на голову. Вместо того чтобы сначала писать код, а потом гадать, работает ли он, вы сначала пишете тест, который должен провалиться, а затем пишете ровно столько кода, чтобы тест прошёл.

Rust — язык, который уже на уровне компилятора защищает вас от целого класса ошибок (долой segfaults и гонки данных!). Но даже компилятор не гарантирует, что ваша логика верна. TDD в Rust добавляет к этой безопасности ещё один слой: вы не просто пишете код, а документируете ожидаемое поведение прямо в тестах. И это невероятно круто.

В этой статье мы разберём TDD в Rust на живых примерах. Вы увидите, как писать тесты до реализации, как запускать их с помощью cargo test и как рефакторить код, не боясь что-то сломать. Поехали!

Что такое TDD и почему Rust для него идеален?

TDD расшифровывается как Test-Driven Development (разработка через тестирование). Цикл простой и элегантный:

  1. Красный — пишем тест, который описывает новую функциональность. Запускаем — тест падает (логично, кода ещё нет).
  2. Зелёный — пишем минимальный код, чтобы тест прошёл. Не думаем о красоте, только о прохождении теста.
  3. Рефакторинг — улучшаем код, не меняя его поведения. Тесты остаются зелёными.

Rust идеально подходит для TDD по нескольким причинам:

  • Встроенная система тестирования — cargo test работает из коробки, не нужно ставить сторонние фреймворки.
  • Строгая типизация — компилятор отлавливает ошибки типов, а тесты — логические ошибки. Вместе они непобедимы.
  • Владение и заимствование — тесты помогают убедиться, что вы правильно работаете с памятью, не создавая лишних копий.
  • Сообщество и документация — Rust-сообщество активно продвигает тестирование, примеров масса.

Настройка проекта для TDD

Создадим новый проект:

cargo new tdd_rust_example
cd tdd_rust_example

По умолчанию cargo new создаёт файл src/main.rs с простейшей программой. Мы будем писать библиотеку, поэтому переименуем его в src/lib.rs (или просто удалим main.rs и создадим lib.rs). В Cargo.toml ничего менять не нужно — тесты уже включены.

Кстати, если вы пишете только библиотеку (без исполняемого файла), используйте cargo new --lib.

Первый тест: красный

Представьте, что мы хотим написать функцию, которая складывает два числа. По TDD мы сначала пишем тест. Откроем src/lib.rs и добавим:

#[cfg(test)]
mod tests {
    #[test]
    fn test_add() {
        let result = add(2, 3);
        assert_eq!(result, 5);
    }
}

Запускаем:

cargo test

Вы увидите ошибку компиляции — функция add не определена. Это нормально. Мы в красной зоне.

Зелёный: минимальная реализация

Теперь напишем ровно столько кода, чтобы тест прошёл. Добавим функцию add выше модуля тестов:

fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_add() {
        let result = add(2, 3);
        assert_eq!(result, 5);
    }
}

Обратите внимание на use super::*; — это импортирует всё из родительского модуля (нашего кода) в модуль тестов.

Снова запускаем cargo test. Зелёный! Тест проходит. Мы сделали минимум — вернули сумму. Кажется, что это слишком просто, но именно так TDD и работает: маленькими шагами.

Рефакторинг: улучшаем, не ломая

Теперь, когда тест зелёный, мы можем рефакторить код. Например, добавим обработку переполнения (хотя для i32 это не так критично, но ради примера). Меняем реализацию:

fn add(a: i32, b: i32) -> i32 {
    a.checked_add(b).unwrap_or(i32::MAX)
}

Запускаем тесты — они всё ещё проходят? Да. Но если бы мы ошиблись, тесты бы упали. Рефакторинг под защитой тестов — это магия.

Второй цикл TDD: тестируем более сложную логику

Допустим, мы хотим написать функцию, которая проверяет, является ли строка палиндромом. Снова начинаем с теста.

Красный

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_palindrome() {
        assert!(is_palindrome("racecar"));
        assert!(is_palindrome("level"));
        assert!(!is_palindrome("hello"));
    }
}

Запускаем — ошибка, функция не определена.

Зелёный

Пишем минимальную реализацию. Пока без обработки пробелов и регистра — только для простых строк:

fn is_palindrome(s: &str) -> bool {
    s.chars().eq(s.chars().rev())
}

Тест зелёный? Отлично. Но тут есть нюанс: racecar — палиндром, hello — нет. Всё верно.

Рефакторинг

Мы можем улучшить функцию, чтобы она игнорировала пробелы и регистр. Добавим новый тест (снова красный):

#[test]
fn test_is_palindrome_with_spaces() {
    assert!(is_palindrome("A man a plan a canal Panama"));
}

Тест падает, потому что наша функция не убирает пробелы и не приводит к нижнему регистру. Рефакторим:

fn is_palindrome(s: &str) -> bool {
    let cleaned: String = s.chars()
        .filter(|c| c.is_alphanumeric())
        .map(|c| c.to_ascii_lowercase())
        .collect();
    cleaned.chars().eq(cleaned.chars().rev())
}

Теперь все тесты зелёные. Мы прошли ещё один цикл TDD.

Интеграционные тесты

Блоги

Рекомендаций по книгам