Моки и фикстуры в Rust: как тестировать код без боли

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

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

Начать курс

Введение: Почему тесты в 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                

Блоги

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