← Назад к списку тем

16. Тестирование

#[test], integration tests, mocking, property-based testing (proptest), бенчмарки (criterion), doctest, fuzzing.

Модульные тесты — #[test] и cfg(test)

В Rust тесты — часть языка и Cargo, а не отдельный фреймворк. Модульные (unit) тесты обычно располагаются прямо в файле рядом с тестируемым кодом, во вложенном модуле tests, помеченном #[cfg(test)] — это гарантирует, что тестовый код не попадёт в релизный бинарник.

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

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

    #[test]
    fn adds_two_positive_numbers() {
        assert_eq!(add(2, 3), 5);
    }

    #[test]
    #[should_panic(expected = "overflow")]
    fn panics_on_overflow() {
        let _ = i32::MAX.checked_add(1).expect("overflow");
    }

    #[test]
    fn returns_result() -> Result<(), String> {
        if add(1, 1) == 2 {
            Ok(())
        } else {
            Err("math is broken".to_string())
        }
    }
}

Тест считается провалившимся, если функция паникует, либо (для теста, возвращающего Result) возвращает Err. Второй вариант удобен, когда внутри теста используется оператор ? вместо цепочки unwrap().

🔑 Компиляция тестов: команда cargo test собирает крейт дважды — обычную библиотеку/бинарник и отдельный тестовый харнесс с cfg(test), включённым только во второй сборке. Поэтому тестовый код не увеличивает размер продакшен-бинарника.

Организация: unit vs integration тесты

Интеграционные тесты живут в отдельной директории tests/ на уровне крейта — каждый файл там компилируется как отдельный крейт и видит только публичный API вашей библиотеки, как внешний потребитель.

// tests/api_integration.rs
use my_crate::{Client, Config};

#[test]
fn client_connects_with_default_config() {
    let client = Client::new(Config::default());
    assert!(client.is_ready());
}
КритерийUnit-тесты (src/, cfg(test))Integration-тесты (tests/)
Доступ к кодук приватным полям и функциям модулятолько к публичному API крейта
Скорость сборкикомпилируются вместе с крейтомкаждый файл — отдельный крейт, компилируется дольше
Назначениепроверка отдельной функции/структурыпроверка поведения крейта "снаружи"
Общий кодсоседние функции модулявынесите в tests/common/mod.rs

Общий вспомогательный код для интеграционных тестов кладут в tests/common/mod.rs — именно mod.rs, а не common.rs, чтобы Cargo не считал этот файл отдельным тестовым крейтом.

Doctest — тесты в документации

Примеры кода в doc-комментариях (///) компилируются и выполняются как тесты командой cargo test. Это гарантирует, что документация никогда не "врёт" — устаревший пример просто сломает сборку.

/// Складывает два числа.
///
/// # Examples
///
/// ```
/// use my_crate::add;
///
/// assert_eq!(add(2, 2), 4);
/// ```
///
/// # Panics
///
/// Паникует при переполнении в debug-режиме:
///
/// ```should_panic
/// # use my_crate::add;
/// add(i32::MAX, 1);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

Строки, начинающиеся с # внутри блока кода, скрываются в отрендеренной документации, но по-прежнему компилируются — удобно прятать вспомогательный boilerplate (импорты, setup). Атрибут ```ignore``` исключает пример из выполнения, ```no_run``` — компилирует, но не запускает.

Property-based testing — proptest

Вместо того чтобы вручную придумывать тестовые случаи, property-based тестирование генерирует сотни случайных входов и проверяет, что некоторое свойство (инвариант) выполняется всегда. Библиотека proptest — стандартный выбор в экосистеме Rust (аналог QuickCheck из Haskell).

use proptest::prelude::*;

fn reverse<T: Clone>(input: &[T]) -> Vec<T> {
    let mut v = input.to_vec();
    v.reverse();
    v
}

proptest! {
    #[test]
    fn double_reverse_is_identity(xs in proptest::collection::vec(any::<i32>(), 0..100)) {
        let reversed_twice = reverse(&reverse(&xs));
        prop_assert_eq!(reversed_twice, xs);
    }

    #[test]
    fn parse_never_panics(s in ".*") {
        let _ = s.parse::<i64>(); // не должно паниковать ни при каком вводе
    }
}

При падении теста proptest автоматически выполняет shrinking — минимизирует найденный контрпример до самого маленького, на котором свойство всё ещё нарушается, что сильно упрощает отладку.

Моки и тестовые двойники

В Rust нет встроенной поддержки моков в стиле Mockito/Moq — вместо перехвата вызовов на рантайме идиоматичный подход опирается на трейты и внедрение зависимостей: код зависит от трейта, а в тестах подставляется тестовая реализация.

trait PaymentGateway {
    fn charge(&self, cents: u64) -> Result<String, String>;
}

struct OrderService<G: PaymentGateway> {
    gateway: G,
}

impl<G: PaymentGateway> OrderService<G> {
    fn checkout(&self, cents: u64) -> Result<String, String> {
        self.gateway.charge(cents)
    }
}

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

    struct MockGateway { should_fail: bool }

    impl PaymentGateway for MockGateway {
        fn charge(&self, _cents: u64) -> Result<String, String> {
            if self.should_fail {
                Err("declined".into())
            } else {
                Ok("tx_123".into())
            }
        }
    }

    #[test]
    fn checkout_fails_when_gateway_declines() {
        let service = OrderService { gateway: MockGateway { should_fail: true } };
        assert!(service.checkout(500).is_err());
    }
}

Для сложных сценариев с записью и проверкой вызовов используют крейты mockall (генерирует моки через derive-подобный proc-macro #[automock]) или ручные fake-объекты со счётчиками вызовов на RefCell/Cell.

✅ Совет: предпочитайте generic-параметры (OrderService<G: PaymentGateway>) вместо dyn PaymentGateway в горячем пути — мономорфизация даёт статическую диспетчеризацию без потери тестируемости, а dyn Trait оставляйте для мест, где нужна рантайм-полиморфность (плагины, коллекции разнородных объектов).

Бенчмарки — criterion

Встроенный #[bench] доступен только в nightly Rust, поэтому стандартом де-факто стал крейт criterion — он работает на stable, использует статистический анализ (доверительные интервалы, обнаружение выбросов) и строит графики регрессий между запусками.

// benches/fib_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 1,
        1 => 1,
        n => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn bench_fibonacci(c: &mut Criterion) {
    c.bench_function("fib 20", |b| {
        b.iter(|| fibonacci(black_box(20)))
    });
}

criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);

black_box запрещает компилятору "вычислить результат заранее" или выкинуть код как мёртвый — без него агрессивный инлайнинг и constant folding могут сделать бенчмарк бессмысленным. Запуск: cargo bench, результаты сохраняются в target/criterion/ с HTML-отчётами.

Fuzzing — cargo-fuzz

Fuzzing генерирует случайные (или мутированные из корпуса) байтовые последовательности и скармливает их функции в поисках паники, паники по переполнению или UB под Miri/ASan. Инструмент cargo-fuzz оборачивает LLVM's libFuzzer.

// fuzz/fuzz_targets/parse.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = my_crate::parse_config(s); // не должно паниковать/UB на любом вводе
    }
});
$ cargo install cargo-fuzz
$ cargo fuzz init
$ cargo fuzz run parse

Fuzzing особенно ценен для парсеров, десериализаторов и любого кода, работающего с недоверенным вводом — именно там чаще всего прячутся паники на edge-case и index-out-of-bounds, которые не приходят в голову при ручном написании тестов.

Практики организации тестов

  • Табличные тесты — набор входов/ожидаемых выходов в массиве кортежей, прогоняемый циклом, вместо копипасты десятка похожих #[test]-функций.
  • Test fixtures — общие данные и setup выносятся в helper-функции модуля tests, а не дублируются.
  • cargo test -- --test-threads=1 — форсирует последовательный запуск, полезно при тестах с общим глобальным состоянием (файлы, порты, статики).
  • #[ignore] — помечает медленные/внешние тесты, запускаемые отдельно через cargo test -- --ignored.
  • cargo llvm-cov / cargo tarpaulin — измерение покрытия кода тестами.
#[test]
#[ignore = "требует запущенной БД"]
fn integration_with_real_database() {
    // ...
}
⚠️ Параллельность по умолчанию: cargo test запускает тесты в разных потоках одного процесса. Тесты, читающие/пишущие переменные окружения, временные файлы с фиксированным именем или слушающие один и тот же порт, будут "мигать" (flaky) без явной синхронизации или изоляции (например, уникальные имена файлов, tempfile, отдельные порты через 0).