Почему TDD в Rust — это не скучно, а выгодно?
Вы когда-нибудь писали код, который работал «вроде бы правильно», а потом ловили баг в самом неожиданном месте? Знакомо. Разработка через тестирование (TDD) — это метод, который переворачивает привычный процесс с ног на голову. Вместо того чтобы сначала писать код, а потом гадать, работает ли он, вы сначала пишете тест, который должен провалиться, а затем пишете ровно столько кода, чтобы тест прошёл.
Rust — язык, который уже на уровне компилятора защищает вас от целого класса ошибок (долой segfaults и гонки данных!). Но даже компилятор не гарантирует, что ваша логика верна. TDD в Rust добавляет к этой безопасности ещё один слой: вы не просто пишете код, а документируете ожидаемое поведение прямо в тестах. И это невероятно круто.
В этой статье мы разберём TDD в Rust на живых примерах. Вы увидите, как писать тесты до реализации, как запускать их с помощью cargo test и как рефакторить код, не боясь что-то сломать. Поехали!
Что такое TDD и почему Rust для него идеален?
TDD расшифровывается как Test-Driven Development (разработка через тестирование). Цикл простой и элегантный:
- Красный — пишем тест, который описывает новую функциональность. Запускаем — тест падает (логично, кода ещё нет).
- Зелёный — пишем минимальный код, чтобы тест прошёл. Не думаем о красоте, только о прохождении теста.
- Рефакторинг — улучшаем код, не меняя его поведения. Тесты остаются зелёными.
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.