Rust Тестирование и документация: тесты, doc-тесты, бенчмарки, property-based
0%

Тестирование и документация: тесты, doc-тесты, бенчмарки, property-based

Тестирование и документация: тесты, doc-тесты, бенчмарки, property-based

В предыдущей статье мы вышли за границу, где компилятор перестаёт что-либо гарантировать. Теперь вернёмся и зададим главный вопрос этой главы: что остаётся тестам, когда половину работы уже сделал компилятор?

Ответ определяет всю стратегию. В Python или JavaScript изрядная доля тестов проверяет, что программа вообще не развалится: что не прилетел None, что поле называется так, как думает вызывающий, что список не оказался словарём. В Rust такие тесты писать бессмысленно — их уже написал компилятор, один раз и навсегда.

Что доказывает компилятор и что остаётся вам

Компилятор уже доказал (см. владение, времена жизни, типы и трейты): нет разыменования null, use-after-free и двойного освобождения; нет гонок данных в безопасном коде — Send/Sync проверены статически (конкурентность); match исчерпывающий, добавили вариант перечисления — не собралось, пока не обработали; Result нельзя молча выбросить (обработка ошибок).

Компилятор не докажет никогда: что скидка считается по нужной формуле; что реализация соответствует спецификации протокола; что нет паники — unwrap(), выход за границы среза, переполнение в debug-сборке; что алгоритм не съест 8 ГБ на входе в 10 МБ; что unsafe-блок соблюдает обещанные инварианты; что производительность не упала втрое после «безобидного» рефакторинга.

Отсюда практический вывод: в Rust тестов обычно меньше, но каждый из них про смысл, а не про типы. И отсюда же специфический инструментарий — property-based, фаззинг, Miri, loom: они закрывают ровно те классы, где типы бессильны.

Модель исполнения: что реально делает cargo test

Главное недопонимание новичка — считать cargo test командой «запусти тесты». На деле это «собери несколько разных бинарников и запусти каждый».

Три следствия, которые надо усвоить сразу.

  1. Библиотека для тестов собирается заново. Флаг --cfg test включает #[cfg(test)]-модули: это буквально другой артефакт, а не «тот же плюс тесты».
  2. Тесты одного бинарника идут параллельно, в потоках одного процесса. Отсюда весь класс проблем с глобальным состоянием: переменные окружения, текущий каталог, синглтоны.
  3. Doc-тесты — отдельная вселенная. Их не запускает ни --all-targets, ни cargo nextest. Про это забывают, и документация тихо гниёт.

Карта тестов в крейте: где они живут и что видят

Первый тест и чтение вывода

// src/lib.rs

/// Считает слова, разделённые любым пробельным символом.
pub fn word_count(text: &str) -> usize {
    split_words(text).len()
}

// Приватная функция: снаружи крейта её не существует.
fn split_words(text: &str) -> Vec<&str> {
    text.split_whitespace().collect()
}

#[cfg(test)]
mod tests {
    use super::*; // втягиваем родительский модуль целиком, включая приватное

    #[test]
    fn empty_string() {
        assert_eq!(word_count(""), 0);
    }

    #[test]
    fn two_words() {
        // намеренно неверное ожидание — посмотрим на вывод
        assert_eq!(word_count("привет мир"), 3, "разбиение по пробелам");
    }

    #[test]
    fn sees_private_helper() {
        assert_eq!(split_words("a  b"), vec!["a", "b"]); // приватное здесь видно
    }
}

Модуль #[cfg(test)] mod tests прямо в файле с кодом — идиома, а не небрежность. Причина в модели видимости: дочерний модуль видит приватные элементы родителя, значит белые тесты не требуют «открывать» внутренности через pub(crate) только ради тестов. А cfg(test) гарантирует, что этот код не попадёт в релизный бинарник — ни байта.

Анатомия вывода cargo test

Ключи, которые нужны каждый день (после -- идут аргументы самого harness, а не cargo):

cargo test --lib                 # только unit-тесты библиотеки — самый быстрый цикл
cargo test --doc                 # только doc-тесты
cargo test two_words -- --exact  # ровно один тест
cargo test -- --nocapture        # не глотать println! из проходящих тестов
cargo test -- --test-threads=1   # выключить параллелизм: ловим взаимное влияние
cargo test -- --include-ignored  # вместе с долгими, помеченными #[ignore]
cargo test --no-run              # только собрать бинарники (удобно для кэша CI)

Сообщения компилятора: половина обучения

В тестах вы будете встречать одни и те же четыре диагностики. Научитесь их читать — и отладка станет механической.

1. assert_eq! требует Debug — макрос обязан напечатать значения при падении:

error[E0277]: `Point` doesn't implement `Debug`
  --> src/lib.rs:24:9
   |
24 |         assert_eq!(shift(p, 1), Point { x: 1, y: 0 });
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Point` cannot be formatted using `{:?}`
   |
   = help: the trait `Debug` is not implemented for `Point`
   = note: add `#[derive(Debug)]` to `Point` or manually `impl Debug for Point`

Компилятор буквально диктует решение. 2. assert_eq! требует PartialEq — другая диагностика, другой номер:

error[E0369]: binary operation `==` cannot be applied to type `Point`
   = note: an implementation of `PartialEq` might be missing for `Point`
help: consider annotating `Point` with `#[derive(PartialEq)]`

3. Интеграционный тест не видит приватного:

error[E0603]: function `split_words` is private
 --> tests/api.rs:3:16
  |
3 | use mylib::split_words;
  |            ^^^^^^^^^^^ private function

Это не препятствие, а сигнал: вы тестируете реализацию снаружи. Либо тест переезжает в #[cfg(test)] mod tests, либо функция действительно часть публичного контракта.

4. Владение внутри теста — самая «рустовая» диагностика:

error[E0382]: borrow of moved value: `cfg`
  --> src/lib.rs:41:20
   |
39 |         let cfg = Config::new();
   |             --- move occurs because `cfg` has type `Config`, which does not implement the `Copy` trait
40 |         let engine = Engine::new(cfg);
   |                                  --- value moved here
41 |         assert_eq!(cfg.threads, 4);
   |                    ^^^^^^^^^^^ value borrowed here after move
   |
help: consider cloning the value if the performance cost is acceptable

Соблазн — влепить .clone(). Но подумайте: если тест не может проверить конфигурацию после передачи в движок, то и пользователь не сможет. Возможно, Engine::new должен брать &Config или движок обязан отдавать fn config(&self) -> &Config. Неудобный тест — это первый отчёт о дефекте API. Это самая ценная обратная связь системы владения: она делает плохой дизайн физически некомфортным ещё до появления пользователей.

Интеграционные тесты: только публичный контракт

Файлы в каталоге tests/ — отдельные крейты. Они подключают вашу библиотеку так же, как это сделает чужой проект.

// tests/api.rs
use mylib::word_count;

mod common; // подключает tests/common/mod.rs — но НЕ tests/common.rs

#[test]
fn counts_words_in_real_document() {
    let text = common::load_fixture("doc.txt");
    assert_eq!(word_count(&text), 128);
}

Три классические грабли:

  • tests/common.rs вместо tests/common/mod.rs. Первое cargo считает отдельным тестовым таргетом и печатает running 0 tests. Подкаталог с mod.rs таргетом не считается.
  • Хелперы под #[cfg(test)] не видны из tests/. Для интеграционных тестов библиотека компилируется без --cfg test, и тестовые заглушки туда не попадают. Решение — фича: #[cfg(any(test, feature = "test-util"))] плюс --features test-util в CI.
  • Десяток мелких файлов в tests/. Каждый — отдельная компиляция и линковка со всей библиотекой; на среднем проекте это разница между 20 и 90 секундами. Радикальное решение — один таргет tests/it/main.rs с подмодулями, см. «Delete Cargo Integration Tests» Алексея Кладова.

Водораздел «unit против интеграции» и способы не превратить второе в хрупкое болото — в юнит-тестировании и интеграционном тестировании.

Падения, Result и параметризация

#[test]
#[should_panic(expected = "деление на ноль")] // проверка ПОДСТРОКИ сообщения
fn div_by_zero_panics() {
    divide(1, 0);
}

// Тест может вернуть Result — внутри работает `?`, подготовка данных короче
#[test]
fn parses_config() -> Result<(), Box<dyn std::error::Error>> {
    let cfg = Config::from_str(include_str!("../fixtures/app.toml"))?;
    assert_eq!(cfg.port, 8080);
    Ok(())
}

// Проверка конкретной ошибки: should_panic и Result несовместимы, помогает matches!
#[test]
fn rejects_negative_port() {
    let err = Config::from_str("port = -1").unwrap_err();
    assert!(matches!(err, ConfigError::OutOfRange { .. }), "неожиданно: {err:?}");
}

#[should_panic] без expected зеленеет на любой панике, включая опечатку в подготовке данных, — всегда указывайте подстроку. assert_matches! в стандартной библиотеке до сих пор нестабилен, на stable используйте matches! или крейт assert_matches.

Из мелких удобств стоит поставить сразу три вещи: pretty_assertions — цветной построчный diff вместо стены текста; rstest — параметризация через #[case(...)], где каждый случай становится отдельным тестом с именем в отчёте; insta — снапшоты для больших текстовых результатов с интерактивным cargo insta review. У снапшотов известная опасность: их слишком легко «принять не глядя», и тест превращается в фиксацию текущего поведения вместо проверки требований.

Файловую систему трогайте только через tempfile (tempfile::tempdir() удаляется в Drop — RAII работает и в тестах), а не через жёстко зашитый /tmp/test.txt: параллельные тесты подерутся за имя. Отдельная мина 2024-й редакции: std::env::set_var теперь unsafe, потому что менять окружение процесса, где параллельно бегут потоки-тесты, небезопасно. Правильный ответ — не «завернуть в unsafe», а передавать конфигурацию параметром, читая окружение один раз на входе в программу; в крайнем случае сериализовать такие тесты через serial_test.

Doc-тесты: документация, которая не может протухнуть

Фича, которой в Rust стоит гордиться: любой пример кода в ///-комментарии компилируется и запускается при cargo test. Документация, разошедшаяся с кодом, ломает сборку.

/// Нормализует пробелы: схлопывает подряд идущие и обрезает края.
///
/// # Примеры
///
/// ```
/// use mylib::normalize;
///
/// assert_eq!(normalize("  привет   мир "), "привет мир");
/// # assert_eq!(normalize("   "), "");  // строка скрыта в документации, но выполняется
/// ```
///
/// # Паники
///
/// Не паникует никогда.
pub fn normalize(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

Атрибуты блока управляют режимом: no_run — компилировать, но не запускать (пример лезет в сеть); should_panic — ожидаем панику; ignore — не трогать вообще (почти всегда признак лени); compile_fail — код обязан не собраться. Последний особенно интересен: им тестируют отрицательные свойства системы типов.

/// ```compile_fail
/// // новичок не должен суметь перепутать идентификаторы
/// let id: mylib::UserId = mylib::OrderId::new(1);
/// ```

Компилятор становится частью тестового набора: вы проверяете не только «работает как надо», но и «невозможно использовать неправильно». Падение doc-теста выглядит так:

---- src/lib.rs - normalize (line 7) stdout ----
error[E0599]: no method named `normalise` found for struct `String`
 --> src/lib.rs:9:26
  |
3 | assert_eq!(normalize("  привет   мир ").normalise(), "привет мир");
  |                                         ^^^^^^^^^ help: there is a method with a similar name: `normalize`

test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

Практика:

  • README тоже тестируется: #![doc = include_str!("../README.md")] в src/lib.rs превращает примеры из README в doc-тесты. Ни одного устаревшего сниппета на главной странице проекта.
  • Для бинарных крейтов doc-тестов нет. Логика в main.rs ими не покрывается вовсе — ещё один аргумент за схему «тонкий main.rs плюс вся логика в библиотеке», к которой мы вернёмся в статье про продакшен. Для CLI остаются assert_cmd и trycmd, гоняющие настоящий бинарник и сверяющие stdout/stderr и код возврата.
  • Doc-тесты медленные: каждый — своя компиляция и линковка. Сотня примеров легко добавляет минуту; в nightly ведётся работа над объединением их в один бинарник, на stable просто не гоняйте --doc на каждое нажатие клавиши.
  • Что обязано быть в документации публичного API (разделы # Примеры, # Паники, # Ошибки, # Безопасность), формализовано в Rust API Guidelines.

Тестопригодность через трейты, а не через магию

В Rust нет рефлексии и подмены методов в рантайме. Мок — это обычный тип, реализующий тот же трейт. Значит тестопригодность закладывается в дизайн: если функция сама создаёт HTTP-клиент и читает системные часы, подменить их нечем.

use std::time::{Duration, SystemTime};

pub trait Clock: Send + Sync {                       // зависимость от времени — явная
    fn now(&self) -> SystemTime;
}
pub struct SystemClock;
impl Clock for SystemClock {
    fn now(&self) -> SystemTime { SystemTime::now() }
}

pub struct TokenService<C: Clock> { clock: C, ttl: Duration }

impl<C: Clock> TokenService<C> {
    pub fn is_expired(&self, issued_at: SystemTime) -> bool {
        self.clock.now().duration_since(issued_at).unwrap_or_default() > self.ttl
    }
}

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

    struct FrozenClock(SystemTime);          // фиксированные часы — три строки,
    impl Clock for FrozenClock {             // никаких библиотек и «магии»
        fn now(&self) -> SystemTime { self.0 }
    }

    #[test]
    fn token_expires_after_ttl() {
        let issued = SystemTime::UNIX_EPOCH;
        let svc = TokenService { clock: FrozenClock(issued + Duration::from_secs(61)),
                                 ttl: Duration::from_secs(60) };
        assert!(svc.is_expired(issued), "при ttl=60 через 61 с токен протух");
    }
}

Выбор между C: Clock (мономорфизация: ноль накладных расходов, но раздувание кода и времени сборки) и Box<dyn Clock> (одна копия кода, косвенный вызов) разобран в статье про типы и трейты. Практическое правило: dyn там, где полиморфизм нужен один раз при старте приложения; дженерики — в горячем коде.

Когда трейт большой, ручные заглушки надоедают: mockall по #[cfg_attr(test, mockall::automock)] генерирует мок с ожиданиями (expect_find().withf(|id| *id == 42).times(1).returning(...)). Но для внешних систем предпочитайте не моки, а настоящие двойники: wiremock поднимает локальный HTTP-сервер, testcontainers — реальный Postgres в Docker на время теста. Проверять сериализацию запроса моком клиента — самообман; проверять её настоящим сервером — тест.

Асинхронные тесты

#[test] не умеет async fn — нужен рантайм и его атрибут (документация tokio):

#[tokio::test]
async fn fetches_user() { /* однопоточный рантайм по умолчанию */ }

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn handles_concurrent_requests() { /* если проверяем реальный параллелизм */ }

// Виртуальное время: часы прыгают вперёд, как только все задачи заснули
#[tokio::test(start_paused = true)]
async fn retries_with_backoff() {
    let start = tokio::time::Instant::now();
    let res = with_retry(3, || async { Err::<(), _>("временный сбой") }).await;
    assert!(res.is_err());
    // задержки 1 + 2 + 4 секунды учтены, но тест отработал мгновенно
    assert_eq!(start.elapsed(), std::time::Duration::from_secs(7));
}

Убийца тестовых наборов — реальное ожидание: тест ретраев честно спит семь секунд, и через полгода CI идёт двадцать минут. Другие подводные камни (подробнее про async): блокирующий вызов внутри #[tokio::test] подвешивает однопоточный рантайм целиком; забытый .await даёт лишь предупреждение unused_must_use, а тест зеленеет, ничего не выполнив; «иногда падает» почти всегда означает, что вы полагаетесь на порядок выполнения задач, которого рантайм не обещал.

Property-based: проверяем свойства, а не примеры

Пример-тест отвечает на вопрос «работает ли на этом входе». Property-тест — на вопрос «выполняется ли инвариант на всех входах области определения», и контрпример ищет сам.

Модель работы proptest: стратегия порождает значение → выполняется тело теста → при падении начинается сжатие (shrinking): библиотека упрощает контрпример, пока он не перестанет падать, и показывает минимальный. Найденное записывается в proptest-regressions/, и этот файл коммитится: случайно найденный баг превращается в постоянный регрессионный тест.

use proptest::prelude::*;

proptest! {
    // Свойство 1: кодирование и декодирование взаимно обратны
    #[test]
    fn encode_decode_roundtrip(s in "\\PC*") {
        let decoded = decode(&encode(&s)).expect("валидный вход декодируется");
        prop_assert_eq!(s, decoded);
    }

    // Свойство 2: своя сортировка совпадает с эталонной (модельное тестирование)
    #[test]
    fn my_sort_matches_std(mut v in prop::collection::vec(any::<i32>(), 0..500)) {
        let mut expected = v.clone();
        expected.sort();
        my_sort(&mut v);
        prop_assert_eq!(v, expected);
    }
}
thread 'tests::encode_decode_roundtrip' panicked at src/lib.rs:88:1:
Test failed: assertion failed: `(left == right)`
  left: `"a\u{0}"`, right: `"a"`.
minimal failing input: s = "a\u{0}"
	successes: 37
	local rejects: 0
	global rejects: 0
Saving this and future failures in proptest-regressions/lib.txt

Обратите внимание на minimal failing input: не «упало на строке из двухсот случайных символов», а «упало на "a\0"». Сжатие — главная ценность подхода, именно оно превращает случайный шум в осмысленный баг-репорт.

Где property-based окупается лучше всего: парсеры и сериализаторы (свойство round-trip ловит львиную долю ошибок); структуры данных (инварианты после последовательности операций, сравнение с Vec/BTreeMap как эталоном); арифметика и конверсии типов (i32::MIN, переполнения); ручные реализации Ord (транзитивность и антисимметрия ломаются на раз). Альтернатива — quickcheck, портированный из Haskell: генераторы задаются типами, поэтому запись короче, а сжатие слабее; разницу подходов хорошо объясняет статья «Integrated vs type based shrinking». Честная цена: по умолчанию 256 случаев на свойство, каждый — полное тело теста; лимит регулируется ProptestConfig { cases: 32, .. }, тяжёлые свойства уносят в ночной прогон.

Фаззинг: когда вход — произвольные байты

Фаззинг — родственник property-based с другой механикой: генератор не типизированный, а байтовый, и он направляется покрытием кода. libFuzzer мутирует вход, смотрит, какие новые ветки открылись, и копает туда. Лучший способ проверить парсер недоверенного формата.

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

fuzz_target!(|data: &[u8]| {
    // Цель — не корректность, а отсутствие паник, зависаний и UB
    let _ = mylib::parse_packet(data);
});
cargo install cargo-fuzz
cargo fuzz init
cargo fuzz run parse -- -max_total_time=300   # пять минут на каждый PR

Крейт arbitrary позволяет фаззить не байты, а структуры: fuzz_target!(|cfg: Config| ...) превращает байтовый поток в осмысленный объект. Найденный вход сохраняется в fuzz/artifacts/ и должен переехать в обычный тест как регрессия. Подробности — в Rust Fuzz Book. Правило простое: если крейт принимает данные из сети или из файла, который написали не вы, фаззинг обязателен — паника в парсере остаётся практически единственным способом уронить процесс из безопасного Rust.

Miri, санитайзеры и loom

Miri — интерпретатор MIR, выполняющий ваши тесты и следящий за неопределённым поведением: висячие указатели, нарушения правил алиасинга (модель Stacked/Tree Borrows), невыровненный доступ, чтение неинициализированной памяти. Для кода с unsafe — обязателен.

#[test]
fn two_mutable_aliases() {
    let mut x = 42i32;
    let p = &mut x as *mut i32;
    let r1 = unsafe { &mut *p };
    let r2 = unsafe { &mut *p };  // второй &mut на ту же память
    *r1 += 1;
    *r2 += 1;                     // UB: тег r1 уже сброшен со стека заимствований
    assert_eq!(x, 44);
}

Обычный cargo test этот тест проходит — печатает 44, всё «работает». А cargo +nightly miri test:

error: Undefined Behavior: attempting a write access using <1234> at alloc1[0x0],
       but that tag does not exist in the borrow stack for this location
   --> src/lib.rs:8:5
    |
8   |     *r1 += 1;
    |     ^^^^^^^^ this error occurs as part of an access at alloc1[0x0..0x4]

Это ровно тот класс ошибок, который в C++ живёт годами и стреляет через год после релиза при смене версии компилятора. Ограничения: Miri в десятки-сотни раз медленнее и не умеет большинство системных вызовов и FFI — гоняйте под ним изолированные тесты unsafe-ядра, а не весь набор (см. unsafe и FFI и репозиторий Miri).

Санитайзеры LLVM доступны на nightly: RUSTFLAGS="-Zsanitizer=address" cargo +nightly test, также thread, leak, memory. Они ловят то, что видно только в реальном исполнении, включая FFI, где Miri бессилен. Их механика — теневая память и перехват аллокаций — прямо смыкается с темой управления памятью в ОС.

loom — модельная проверка конкурентного кода: перебирает возможные перестановки операций в модели памяти C++11 и находит гонки, случающиеся раз в миллион запусков.

#[test]
fn concurrent_push_is_linearizable() {
    loom::model(|| {                    // замыкание прогоняется много раз, каждый
        let stack = loom::sync::Arc::new(MyStack::new()); // раз с другим порядком
        let s2 = stack.clone();                           // переключений потоков
        let t = loom::thread::spawn(move || s2.push(1));
        stack.push(2);
        t.join().unwrap();
        assert_eq!(stack.len(), 2);
    });
}

Число перестановок растёт комбинаторно, поэтому модель должна быть крошечной: два потока, две-три операции. Инструмент нужен тем, кто пишет свои примитивы синхронизации (см. конкурентность и планирование потоков); для прикладного кода на каналах и мьютексах он избыточен.

Мутационное тестированиеcargo-mutants — портит ваш код (меняет return true на return false, выкидывает тело функции) и смотрит, поймают ли это тесты. Отличный способ узнать, что покрытие 90% состояло из тестов, ничего не утверждающих. Цена — полный прогон набора на каждую мутацию.

Бенчмарки: измерять, а не догадываться

Встроенный #[bench] до сих пор доступен только в nightly, поэтому стандарт де-факто — criterion: статистика, сравнение с предыдущим прогоном, HTML-отчёты.

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "parse"
harness = false        # обязательно: иначе libtest перехватит аргументы
// benches/parse.rs
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;

fn bench_parse(c: &mut Criterion) {
    let input = "слово ".repeat(1024);          // данные готовим СНАРУЖИ измерения
    c.bench_function("word_count/1024", |b| {
        // black_box не даёт оптимизатору выкинуть вычисление как мёртвый код
        b.iter(|| black_box(mylib::word_count(black_box(&input))));
    });
}

criterion_group!(benches, bench_parse);
criterion_main!(benches);
word_count/1024         time:   [37.234 µs 37.362 µs 37.510 µs]
                        change: [-12.436% -11.987% -11.523%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 100 measurements (4.00%)

Читать так: три числа в time — нижняя граница доверительного интервала, оценка, верхняя граница; change появляется при сравнении с сохранённым прогоном, p = 0.00 < 0.05 — статистическая значимость; outliers — сигнал о шуме на машине.

Грабли бенчмаркинга именно в Rust:

  • Замер в debug-сборке. cargo bench использует профиль bench с оптимизациями, а cargo test --benches — нет. Разница легко в 10–50 раз и непропорциональна: debug-числа не говорят о релизе ничего.
  • Оптимизатор выкидывает измеряемое. Без black_box LLVM видит, что результат не используется, и удаляет вызов целиком: получите «0.3 нс» и поверите.
  • Замер аллокатора вместо алгоритма. Если внутри b.iter создаётся Vec, вы меряете malloc.
  • Профиль не совпадает с релизным. LTO и codegen-units меняют инлайнинг — измеряете другой бинарник.

Молодая альтернатива — divan: проще API, быстрее старт, умеет считать аллокации. Методология (разогрев, перцентили, coordinated omission, значимость) подробно разобрана в бенчмаркинге и измерениях, а поиск горячих мест — в профилировании CPU. Бенчмарк без профиля — гадание; профиль без бенчмарка — оптимизация вслепую.

Покрытие меряют через cargo-llvm-cov (инструментация LLVM, точнее трассировки tarpaulin): cargo llvm-cov --workspace --html, --lcov --output-path lcov.info для CI, --doctests — с учётом doc-тестов. Честно о цифре: в Rust покрытие систематически «завышено» относительно динамических языков — ветки обработки ошибок прячутся внутри ?, а невозможные состояния просто непредставимы. Ставьте порог «не падать ниже достигнутого», а качество утверждений проверяйте cargo-mutants, а не процентом.

Прогон в CI и цена компиляции

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo test --workspace --no-default-features        # фичи не должны ломать сборку
cargo hack check --feature-powerset --no-dev-deps   # комбинации фич
cargo llvm-cov --workspace --lcov --output-path lcov.info

cargo-nextest заслуживает отдельного упоминания: он запускает каждый тест отдельным процессом. Плюсы — изоляция (падение по SIGSEGV не убивает весь бинарник), честные таймауты, повторы для карантина, вывод в JUnit, обычно в 1.5–3 раза быстрее на больших наборах. Минус ровно один и важный: doc-тесты он не поддерживает, их надо гонять отдельной командой cargo test --doc. Забудете — весь блок примеров окажется непроверенным.

Теперь честно о цене, потому что это главная жалоба на Rust.

  • Тесты — это второй полный билд. Крейт пересобирается с --cfg test, плюс по бинарнику на каждый файл в tests/, плюс линковка каждого. Для среднего сервиса на tokio/serde/sqlx холодная сборка тестов — единицы минут, инкрементальная после правки одной функции — десятки секунд.
  • [dev-dependencies] бьют по времени сборки сильнее, чем кажется. criterion, proptest, testcontainers тянут десятки крейтов; в релиз они не попадают, но каждый прогон тестов их линкует.
  • Мономорфизация умножает работу. Обильно обобщённый код плюс много тестовых инстанцирований — взрывной рост кодогенерации. dyn Trait в холодных местах экономит и размер бинарника, и минуты сборки.

Что реально помогает:

# зависимости с оптимизацией, свой код — без: тяжёлые property-тесты ускоряются в разы
[profile.test]
opt-level = 0
[profile.test.package."*"]
opt-level = 2

Плюс линковщик lld/mold, объединение мелких интеграционных таргетов в один, cargo nextest, кэш target/ в CI, разделение монолитного крейта на воркспейс, чтобы правка не пересобирала всё. Организация самого пайплайна — в тестах в CI.

Типичные грабли списком

  1. cargo test --release меняет семантику: исчезают проверки переполнения целых и debug_assert!. Тест, ловивший переполнение, молча зеленеет.
  2. cargo test --all-targets не запускает doc-тесты — CI может годами их не проверять.
  3. Тесты в одном бинарнике делят процесс: текущий каталог, окружение, глобальные логгеры. --test-threads=1 лечит симптом; правильный ответ — убрать глобальное состояние.
  4. #[should_panic] без expected зеленеет на любой панике.
  5. Заглушки под #[cfg(test)] невидимы из tests/ — нужна фича.
  6. tests/common.rs вместо tests/common/mod.rs создаёт пустой тестовый таргет.
  7. assert!(a == b) вместо assert_eq!(a, b): при падении вы не увидите значений.
  8. Тест, зависящий от порядка итерации HashMap. Он случаен от запуска к запуску (защита от HashDoS) — берите BTreeMap или сортируйте.
  9. Сравнение чисел с плавающей точкой на равенство — нужен допуск (a - b).abs() < 1e-9.
  10. Забытый .await в async-тесте: future создан, но не выполнен, тест зелёный и бессмысленный.
  11. sleep(Duration::from_millis(100)) «чтобы успело» — так рождаются мигающие тесты.
  12. Бенчмарк без black_box, измеряющий пустоту.
  13. unwrap() в тестах допустим (паника и есть провал), но expect("что именно сломалось") экономит минуты при разборе красного CI.

Где Rust выигрывает, а где проигрывает

Выигрывает. Целые категории тестов просто не нужны: на null, на типы, на гонки данных, на забытую ветку match. Инструментарий встроен: тесты, doc-тесты, бенчмарки — одна команда, без выбора между пятью фреймворками. Doc-тесты — уникальная гарантия, что документация не врёт. Miri и loom дают уровень проверки корректности, недоступный в C и C++ без коммерческих инструментов. И самое ценное: система владения делает плохой дизайн болезненным уже на этапе написания теста.

Проигрывает. Цикл обратной связи медленный: правка → пересборка → прогон в Rust всегда дольше, чем в Python или Go. Моки — это код, который надо писать: без рефлексии метод «на лету» не подменишь, и для крупных трейтов это рутина. Экосистема тестовых утилит моложе: зрелых аналогов Java-библиотек по работе с БД, контейнерами и отчётностью меньше, а стабильность API у молодых крейтов ниже. Property-based и фаззинг требуют отдельного навыка: сформулировать свойство труднее, чем написать пример.

Куда не стоит тащить. Одноразовый скрипт проверки гипотезы или тест, который проживёт неделю, на Rust писать дорого: заплатите временем компиляции и проектированием трейтов там, где хватило бы двадцати строк на Python. Rust оправдан, когда код живёт годами, цена ошибки высока и важна производительность: системные утилиты, парсеры, сетевые сервисы под нагрузкой, встраиваемые системы. Сама же терминология тестирования универсальна — см. TDD и BDD; в треке про системное программирование те же приёмы разбираются применительно к коду рядом с ядром.

Мини-итог

  • cargo test собирает несколько бинарников: unit (видит приватное), интеграционные (только pub), doc-тесты (отдельный прогон). Эта раскладка объясняет 90% недоразумений.
  • Тесты в Rust проверяют смысл: логику, протоколы, отсутствие паник, инварианты unsafe. Типы уже проверил компилятор.
  • Doc-тесты превращают документацию в исполняемый контракт, а compile_fail позволяет тестировать даже «невозможность неправильного использования».
  • Property-based ищет контрпримеры и сжимает их до минимальных; фаззинг делает то же для недоверенных байтов, направляясь покрытием.
  • Miri, санитайзеры и loom закрывают то, чего не видит ни один обычный тест: UB в unsafe и редкие перестановки конкурентного кода.
  • Бенчмарки — только criterion/divan, с black_box и релизным профилем, иначе вы измеряете артефакты оптимизатора.
  • Плата за всё — время компиляции. Лечится воркспейсом, nextest, lld/mold, оптимизацией зависимостей в профиле тестов и разумной дозой дженериков.

Источники

Что дальше

Прод на Rust: структура проекта, модули, конфигурация, ошибки в проде — соберём всё вместе: как раскладывать крейты и модули, где живёт конфигурация, как устроены ошибки и логирование в работающем сервисе и что делать, когда прод всё-таки паникует.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов