Введение: Почему тесты в Rust — это не скучно
Если вы когда-нибудь писали тесты для кода на Python или JavaScript, вы наверняка сталкивались с ситуацией: ваш метод вызывает внешнее API, читает файл с диска или обращается к базе данных. Запускать такие тесты в реальном окружении — боль. Они медленные, ненадёжные (вдруг API упало?) и требуют настройки окружения.
В Rust ситуация ничем не отличается. Только вот Rust — язык системный, и здесь тесты должны быть быстрыми, надёжными и детерминированными. Именно для этого нужны mock-объекты и фикстуры.
В этой статье мы разберём, как писать тесты в Rust, которые не зависят от внешнего мира. Вы узнаете, как подменять зависимости, подготавливать данные для тестов и делать это красиво. Поехали!
Что такое mock-объекты и фикстуры?
Mock-объекты (моки)
Mock-объект — это имитация реального компонента системы. Вы создаёте объект, который ведёт себя так, как вам нужно для теста: возвращает заданные значения, выбрасывает ошибки или проверяет, что его методы были вызваны с правильными аргументами.
Представьте, что у вас есть сервис отправки email. В тесте вы не хотите реально отправлять письма — вы используете mock, который просто проверяет, что сервис был вызван с правильным адресом и текстом.
Фикстуры (fixtures)
Фикстуры — это подготовленные данные или состояние, которые нужны для выполнения теста. Например, тестовая база данных, конфигурационный файл, предустановленные структуры. В Rust фикстуры часто реализуются через обычные функции, которые возвращают готовые объекты.
Вместе моки и фикстуры дают вам полный контроль над окружением теста. Вы тестируете только свою логику, а не работу внешних сервисов.
Базовые принципы тестирования в Rust
Прежде чем нырять в моки, давайте вспомним, как устроены тесты в Rust. Обычный модульный тест выглядит так:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_addition() {
assert_eq!(2 + 2, 4);
}
}
Всё просто. Но когда ваш код начинает зависеть от внешних ресурсов (файлы, HTTP-запросы, базы данных), появляются проблемы. Решение — инвертировать зависимости и использовать паттерн Dependency Injection (внедрение зависимостей).
Практический пример: сервис загрузки данных
Допустим, у нас есть структура DataLoader, которая загружает данные из внешнего API и обрабатывает их. Мы хотим протестировать обработку, не вызывая реальный API.
Сначала определим трейт (интерфейс) для источника данных:
pub trait DataSource {
fn fetch_data(&self) -> Result<String, String>;
}
Теперь реализуем реальный источник, который делает HTTP-запрос (упрощённо):
pub struct RealDataSource {
pub url: String,
}
impl DataSource for RealDataSource {
fn fetch_data(&self) -> Result<String, String> {
// Здесь был бы реальный HTTP-запрос
Ok(format!("Данные с {}", self.url))
}
}
И сервис, который использует этот источник:
pub struct DataLoader<T: DataSource> {
source: T,
}
impl<T: DataSource> DataLoader<T> {
pub fn new(source: T) -> Self {
DataLoader { source }
}
pub fn load_and_process(&self) -> Result<String, String> {
let raw_data = self.source.fetch_data()?;
// Какая-то обработка
Ok(format!("Обработано: {}", raw_data))
}
}
Обратите внимание: DataLoader не зависит от конкретной реализации DataSource. Он работает с любым типом, реализующим этот трейт. Это ключ к тестируемости.
Создаём mock-объект вручную
Самый простой способ — создать тестовую структуру, которая реализует DataSource и возвращает заранее заданные значения. Это и есть mock-объект вручную.
#[cfg(test)]
mod tests {
use super::*;
struct MockDataSource {
pub should_fail: bool,
pub data: String,
}
impl DataSource for MockDataSource {
fn fetch_data(&self) -> Result<String, String> {
if self.should_fail {
Err("Ошибка API".to_string())
} else {
Ok(self.data.clone())
}
}
}
#[test]
fn test_loader_success() {
let mock = MockDataSource {
should_fail: false,
data: "тестовые данные".to_string(),
};
let loader = DataLoader::new(mock);
let result = loader.load_and_process();
assert!(result.is_ok());
assert_eq!(result.unwrap(), "Обработано: тестовые данные");
}
#[test]
fn test_loader_failure() {
let mock = MockDataSource {
should_fail: true,
data: String::new(),
};
let loader = DataLoader::new(mock);
let result = loader.load_and_process();
assert!(result.is_err());
}
}
Это работает, но когда методов много, писать mock-объекты вручную становится утомительно. На помощь приходят крейты.
Используем крейт mockall для автоматических моков
Mockall — самый популярный крейт для создания mock-объектов в Rust. Он генерирует mock-структуру автоматически на основе трейта.
Добавим его в Cargo.toml как dev-зависимость:
[dev-dependencies]
mockall = "0.12"
Теперь перепишем наш трейт с атрибутом #[mockall::automock]:
#[cfg_attr(test, mockall::automock)]
pub trait DataSource {
fn fetch_data(&self) -> Result<String, String>;
}
Обратите внимание: атрибут применяется только при компиляции тестов (#[cfg_attr(test, ...)]). В production-сборке он не влияет на код.
Теперь тесты становятся гораздо лаконичнее:
#[cfg(test)]
mod tests {
use super::*;
use mockall::predicate::*;
#[test]
fn test_loader_with_mockall() {
// Создаём mock
let mut mock = MockDataSource::new();
// Настраиваем поведение: метод fetch_data будет вызван 1 раз
mock.expect_fetch_data()
.times(1)
.returning(|| Ok("мок-данные".to_string()));
let loader = DataLoader::new(mock);
let result = loader.load_and_process().unwrap();
assert_eq!(result, "Обработано: мок-данные");
}
#[test]
fn test_loader_error_with_mockall() {
let mut mock = MockDataSource::new();
mock.expect_fetch_data()
.times(1)
.returning(|| Err("Ошибка".to_string()));
let loader = DataLoader