Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Паттерны тестирования для программистов C++

Что вы узнаете: встроенный тестовый фреймворк Rust — #[test], #[should_panic], тесты, возвращающие Result, паттерны-билдеры для тестовых данных, мокирование на основе трейтов, property-based тестирование с proptest, snapshot-тестирование с insta и организацию интеграционных тестов. Тестирование без настройки, которое заменяет Google Test + CMake.

Тестирование в C++ обычно опирается на внешние фреймворки (Google Test, Catch2, Boost.Test) со сложной интеграцией в сборку. Тестовый фреймворк Rust встроен в язык и тулчейн — никаких зависимостей, никакой интеграции с CMake, никакой настройки тестового раннера.

Атрибуты тестов помимо #[test]

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_pass() {
        assert_eq!(2 + 2, 4);
    }

    // Ожидаем panic — аналог EXPECT_DEATH в GTest
    #[test]
    #[should_panic]
    fn out_of_bounds_panics() {
        let v = vec![1, 2, 3];
        let _ = v[10]; // Panic — тест проходит
    }

    // Ожидаем panic с определённой подстрокой в сообщении
    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn specific_panic_message() {
        let v = vec![1, 2, 3];
        let _ = v[10];
    }

    // Тесты, возвращающие Result<(), E> — используйте ? вместо unwrap()
    #[test]
    fn test_with_result() -> Result<(), String> {
        let value: u32 = "42".parse().map_err(|e| format!("{e}"))?;
        assert_eq!(value, 42);
        Ok(())
    }

    // По умолчанию пропускаем медленные тесты — запуск через `cargo test -- --ignored`
    #[test]
    #[ignore]
    fn slow_integration_test() {
        std::thread::sleep(std::time::Duration::from_secs(10));
    }
}
}
cargo test                          # Запустить все не игнорируемые тесты
cargo test -- --ignored             # Запустить только игнорируемые тесты
cargo test -- --include-ignored     # Запустить ВСЕ тесты, включая игнорируемые
cargo test test_name                # Запустить тесты, имя которых соответствует шаблону
cargo test -- --nocapture           # Показывать вывод println! во время тестов
cargo test -- --test-threads=1      # Запускать тесты последовательно (для общего состояния)

Вспомогательные функции для тестов: паттерн «строитель» для тестовых данных

В C++ вы бы использовали фикстуры Google Test (class MyTest : public ::testing::Test). В Rust используйте функции-билдеры или трейт Default:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    // Функция-билдер — создаёт тестовые данные с разумными значениями по умолчанию
    fn make_gpu_event(severity: Severity, fault_code: u32) -> DiagEvent {
        DiagEvent {
            source: "accel_diag".to_string(),
            severity,
            message: format!("Test event FC:{fault_code}"),
            fault_code,
        }
    }

    // Переиспользуемая фикстура — набор заранее созданных событий
    fn sample_events() -> Vec<DiagEvent> {
        vec![
            make_gpu_event(Severity::Critical, 67956),
            make_gpu_event(Severity::Warning, 32709),
            make_gpu_event(Severity::Info, 10001),
        ]
    }

    #[test]
    fn filter_critical_events() {
        let events = sample_events();
        let critical: Vec<_> = events.iter()
            .filter(|e| e.severity == Severity::Critical)
            .collect();
        assert_eq!(critical.len(), 1);
        assert_eq!(critical[0].fault_code, 67956);
    }
}
}

Мокирование с помощью трейтов

В C++ для мокирования нужны фреймворки вроде Google Mock или ручные переопределения virtual-методов. В Rust вы определяете трейт для зависимости и подменяете реализации в тестах:

#![allow(unused)]
fn main() {
// Продакшн-трейт
trait SensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String>;
}

// Продакшн-реализация
struct HwSensorReader;
impl SensorReader for HwSensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
        // Реальный вызов оборудования...
        Ok(72.5)
    }
}

// Тестовый мок — возвращает предсказуемые значения
#[cfg(test)]
struct MockSensorReader {
    temperatures: std::collections::HashMap<u32, f64>,
}

#[cfg(test)]
impl SensorReader for MockSensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
        self.temperatures.get(&sensor_id)
            .copied()
            .ok_or_else(|| format!("Unknown sensor {sensor_id}"))
    }
}

// Тестируемая функция — обобщённая по типу reader
fn check_overtemp(reader: &impl SensorReader, ids: &[u32], threshold: f64) -> Vec<u32> {
    ids.iter()
        .filter(|&&id| reader.read_temperature(id).unwrap_or(0.0) > threshold)
        .copied()
        .collect()
}

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

    #[test]
    fn detect_overtemp_sensors() {
        let mut mock = MockSensorReader { temperatures: Default::default() };
        mock.temperatures.insert(0, 72.5);
        mock.temperatures.insert(1, 91.0);  // Выше порога
        mock.temperatures.insert(2, 65.0);

        let hot = check_overtemp(&mock, &[0, 1, 2], 80.0);
        assert_eq!(hot, vec![1]);
    }
}
}

Временные файлы и каталоги в тестах

В C++ тесты часто используют платформо-специфичные временные каталоги. В Rust есть крейт tempfile:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// tempfile = "3"

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::NamedTempFile;
    use std::io::Write;

    #[test]
    fn parse_config_from_file() -> Result<(), Box<dyn std::error::Error>> {
        // Создаём временный файл, который удаляется автоматически при уничтожении
        let mut file = NamedTempFile::new()?;
        writeln!(file, r#"{{"sku": "ServerNode", "level": "Quick"}}"#)?;

        let config = load_config(file.path().to_str().unwrap())?;
        assert_eq!(config.sku, "ServerNode");
        Ok(())
        // file удаляется здесь — код очистки не нужен
    }
}
}

Property-based тестирование с proptest

Вместо написания конкретных тестовых случаев опишите свойства, которые должны выполняться для всех входных данных. proptest генерирует случайные входные данные и находит минимальные падающие примеры:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// proptest = "1"

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

    fn parse_and_format(n: u32) -> String {
        format!("{n}")
    }

    proptest! {
        #[test]
        fn roundtrip_u32(n: u32) {
            let formatted = parse_and_format(n);
            let parsed: u32 = formatted.parse().unwrap();
            prop_assert_eq!(n, parsed);
        }

        #[test]
        fn string_contains_no_null(s in "[a-zA-Z0-9 ]{0,100}") {
            prop_assert!(!s.contains('\0'));
        }
    }
}
}

Snapshot-тестирование с insta

Для тестов, которые выдают сложный вывод (JSON, отформатированные строки), insta автоматически создаёт и поддерживает эталонные снимки:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// insta = { version = "1", features = ["json"] }

#[cfg(test)]
mod tests {
    use insta::assert_json_snapshot;

    #[test]
    fn der_entry_format() {
        let entry = DerEntry {
            fault_code: 67956,
            component: "GPU".to_string(),
            message: "ECC error detected".to_string(),
        };
        // Первый запуск: создаёт файл снимка в tests/snapshots/
        // Последующие запуски: сравнивают с сохранённым снимком
        assert_json_snapshot!(entry);
    }
}
}
cargo insta test              # Запустить тесты и просмотреть новые/изменённые снимки
cargo insta review            # Интерактивный просмотр изменений снимков

Сравнение тестирования C++ и Rust

C++ (Google Test)RustПримечания
TEST(Suite, Name) { }#[test] fn name() { }Иерархия suite/class не нужна
ASSERT_EQ(a, b)assert_eq!(a, b)Встроенный макрос, фреймворк не нужен
ASSERT_NEAR(a, b, eps)assert!((a - b).abs() < eps)Или используйте крейт approx
EXPECT_THROW(expr, type)#[should_panic(expected = "...")]Или catch_unwind для тонкого контроля
EXPECT_DEATH(expr, "msg")#[should_panic(expected = "msg")]
class Fixture : public ::testing::TestФункции-билдеры + DefaultНаследование не нужно
Google Mock MOCK_METHODТрейт + тестовая реализацияБолее явно, без магии макросов
INSTANTIATE_TEST_SUITE_P (параметризованные)proptest! или тесты, сгенерированные макросом
SetUp() / TearDown()RAII через Drop — очистка автоматическаяПеременные уничтожаются в конце теста
Отдельный тестовый бинарник + CMakecargo test — без настройки
ctest --output-on-failurecargo test -- --nocapture

Интеграционные тесты: каталог tests/

Модульные тесты находятся внутри модулей #[cfg(test)] рядом с вашим кодом. Интеграционные тесты находятся в отдельном каталоге tests/ в корне крейта и проверяют публичный API вашей библиотеки так, как это делал бы внешний потребитель:

my_crate/
├── src/
│   └── lib.rs          # Код вашей библиотеки
├── tests/
│   ├── smoke.rs        # Каждый .rs-файл — отдельный тестовый бинарник
│   ├── regression.rs
│   └── common/
│       └── mod.rs      # Общие вспомогательные функции для тестов (НЕ сам тест)
└── Cargo.toml
#![allow(unused)]
fn main() {
// tests/smoke.rs — тестирует ваш крейт так, как это делал бы внешний пользователь
use my_crate::DiagEngine;  // Доступен только публичный API

#[test]
fn engine_starts_successfully() {
    let engine = DiagEngine::new("test_config.json");
    assert!(engine.is_ok());
}

#[test]
fn engine_rejects_invalid_config() {
    let engine = DiagEngine::new("nonexistent.json");
    assert!(engine.is_err());
}
}
#![allow(unused)]
fn main() {
// tests/common/mod.rs — общие помощники, НЕ компилируются как тестовый бинарник
pub fn setup_test_environment() -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("config.json"), r#"{"log_level": "debug"}"#).unwrap();
    dir
}
}
#![allow(unused)]
fn main() {
// tests/regression.rs — может использовать общие помощники
mod common;

#[test]
fn regression_issue_42() {
    let env = common::setup_test_environment();
    let engine = my_crate::DiagEngine::new(
        env.path().join("config.json").to_str().unwrap()
    );
    assert!(engine.is_ok());
}
}

Запуск интеграционных тестов:

cargo test                          # Запускает и модульные, и интеграционные тесты
cargo test --test smoke             # Запустить только tests/smoke.rs
cargo test --test regression        # Запустить только tests/regression.rs
cargo test --lib                    # Запустить ТОЛЬКО модульные тесты (без интеграционных)

Ключевое отличие от модульных тестов: интеграционные тесты не могут обращаться к приватным функциям или элементам pub(crate). Это заставляет проверять, что публичного API достаточно — полезный сигнал для дизайна. В терминах C++ это похоже на тестирование только по публичному заголовку без доступа friend.