Тестирование в Rust: Unit, Integration, E2E на продвинутом уровне

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

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

Начать курс

Продвинутое тестирование в Rust: от unit до E2E

Тестирование — неотъемлемая часть разработки надёжного системного ПО на Rust. Если базовые концепции (простое написание тестов с #[test]) знакомы каждому, то продвинутые техники — работа с моками, параметризация, кастомные атрибуты и организация E2E-тестов — требуют отдельного разбора. В этой статье мы рассмотрим, как вывести тестирование на Rust на профессиональный уровень, используя unit, integration и end-to-end подходы.

1. Unit-тестирование: mock, параметризация и кастомные атрибуты

Unit-тесты в Rust обычно пишутся в том же файле, что и код, в модуле tests. Однако на продвинутом уровне возникает необходимость изолировать тестируемый код от внешних зависимостей.

Mock-объекты с трейтами

Для создания моков в Rust чаще всего используют трейты. Вы определяете трейт, реализуете его для реального объекта и для тестового мока. Популярные крейты: mockall и mockito (для HTTP).

use mockall::automock;

#[automock]
trait Database {
    fn get_user(&self, id: u32) -> String;
}

fn process_user(db: &dyn Database, id: u32) -> String {
    let user = db.get_user(id);
    format!("User: {}", user)
}

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

    #[test]
    fn test_process_user() {
        let mut mock = MockDatabase::new();
        mock.expect_get_user()
            .with(predicate::eq(42))
            .returning(|_| "Alice".to_string());
        assert_eq!(process_user(&mock, 42), "User: Alice");
    }
}

Параметризованные тесты

Повторение одного и того же теста с разными входными данными — распространённая задача. Крейт rstest предоставляет элегантное решение.

use rstest::rstest;

#[rstest]
#[case(2, 3, 5)]
#[case(0, 0, 0)]
#[case(-1, 1, 0)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
    assert_eq!(a + b, expected);
}

Кастомные тестовые атрибуты

Иногда требуется логика до или после теста. Можно создавать свои атрибуты через процедурные макросы, но проще использовать крейт test-case или ctor для инициализации.

// Пример с ctor для инициализации логгера
#[cfg(test)]
mod tests {
    use ctor::ctor;

    #[ctor]
    fn init() {
        // Инициализация один раз перед всеми тестами
    }
}

2. Интеграционное тестирование: организация и работа с внешними зависимостями

Интеграционные тесты в Rust находятся в папке tests/ на уровне корня проекта. Каждый файл там — отдельный крейт. Продвинутые приёмы включают общие модули и тестирование с реальными БД или API.

Общие утилиты для интеграционных тестов

Чтобы не дублировать код (например, настройку тестового сервера), создайте модуль common внутри tests/.

// tests/common/mod.rs
pub fn setup_test_db() -> String {
    // Настройка тестовой БД
    "connected".to_string()
}

// tests/integration_test.rs
mod common;

#[test]
fn test_db_connection() {
    let db = common::setup_test_db();
    assert_eq!(db, "connected");
}

Работа с HTTP-запросами

Для тестирования HTTP-клиентов используйте mockito — он поднимает локальный mock-сервер.

use mockito::{mock, server_url};

#[tokio::test]
async fn test_api_call() {
    let _m = mock("GET", "/users/1")
        .with_status(200)
        .with_body(r#"{"id":1,"name":"Alice"}"#)
        .create();

    let client = reqwest::Client::new();
    let resp = client.get(&format!("{}/users/1", server_url()))
        .send()
        .await
        .unwrap();
    assert!(resp.status().is_success());
}

3. E2E-тестирование: полный цикл приложения

End-to-end тесты проверяют работу системы целиком: от запуска приложения до проверки результата. В Rust для E2E часто используют субпроцессы или Docker-контейнеры.

Запуск приложения как субпроцесса

Можно скомпилировать бинарник и запустить его в тесте, проверяя вывод.

use std::process::Command;

#[test]
fn test_full_flow() {
    let output = Command::new("cargo")
        .args(&["run", "--", "--input", "test_data.txt"])
        .output()
        .expect("Failed to run app");
    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Success"));
}

Использование Docker для E2E

Для тестов с БД или внешними сервисами удобно использовать testcontainers.

use testcontainers::{Container, Docker, clients::Cli};

#[tokio::test]
async fn test_with_postgres() {
    let docker = Cli::default();
    let container = docker.run(images::postgres::Postgres::default());
    let connection_string = &format!(
        "postgres://postgres:postgres@127.0.0.1:{}/postgres",
        container.get_host_port_ipv4(5432)
    );
    // Используем connection_string для тестов
    assert!(connection_string.contains("postgres"));
}

4. Best practices и частые ошибки

Продвинутое тестирование требует дисциплины. Вот несколько правил:

  • Изолируйте тесты — каждый тест должен быть независимым. Используйте свежие состояния или сбрасывайте данные.
  • Не злоупотребляйте моками — моки полезны для unit-тестов, но для интеграционных лучше использовать реальные зависимости, пусть и в контейнерах.
  • Параметризация — сила — чем больше кейсов покрыто параметризованными тестами, тем выше уверенность в коде.
  • Измеряйте покрытие — используйте cargo tarpaulin или grcov для анализа, но помните

Блоги

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