Продвинутое тестирование в 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для анализа, но помните