Тестирование и документация: тесты, 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 командой «запусти тесты». На деле это «собери несколько разных бинарников и запусти каждый».
Три следствия, которые надо усвоить сразу.
- Библиотека для тестов собирается заново. Флаг
--cfg testвключает#[cfg(test)]-модули: это буквально другой артефакт, а не «тот же плюс тесты». - Тесты одного бинарника идут параллельно, в потоках одного процесса. Отсюда весь класс проблем с глобальным состоянием: переменные окружения, текущий каталог, синглтоны.
- 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) гарантирует, что этот код не попадёт в релизный бинарник — ни байта.
Ключи, которые нужны каждый день (после -- идут аргументы самого 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_boxLLVM видит, что результат не используется, и удаляет вызов целиком: получите «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.
Типичные грабли списком
cargo test --releaseменяет семантику: исчезают проверки переполнения целых иdebug_assert!. Тест, ловивший переполнение, молча зеленеет.cargo test --all-targetsне запускает doc-тесты — CI может годами их не проверять.- Тесты в одном бинарнике делят процесс: текущий каталог, окружение, глобальные логгеры.
--test-threads=1лечит симптом; правильный ответ — убрать глобальное состояние. #[should_panic]безexpectedзеленеет на любой панике.- Заглушки под
#[cfg(test)]невидимы изtests/— нужна фича. tests/common.rsвместоtests/common/mod.rsсоздаёт пустой тестовый таргет.assert!(a == b)вместоassert_eq!(a, b): при падении вы не увидите значений.- Тест, зависящий от порядка итерации
HashMap. Он случаен от запуска к запуску (защита от HashDoS) — беритеBTreeMapили сортируйте. - Сравнение чисел с плавающей точкой на равенство — нужен допуск
(a - b).abs() < 1e-9. - Забытый
.awaitв async-тесте: future создан, но не выполнен, тест зелёный и бессмысленный. sleep(Duration::from_millis(100))«чтобы успело» — так рождаются мигающие тесты.- Бенчмарк без
black_box, измеряющий пустоту. 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, оптимизацией зависимостей в профиле тестов и разумной дозой дженериков.
Источники
- The Rust Programming Language, глава 11 «Writing Automated Tests» — канонический вход в тему.
- The rustdoc book: Documentation tests — все атрибуты doc-тестов.
- The Cargo Book: Cargo Targets — как cargo находит
tests/,benches/,examples/. - Rust API Guidelines: Documentation — что обязано быть в публичной документации.
- proptest book, quickcheck, The Rust Fuzz Book, cargo-fuzz.
- Miri, loom, cargo-mutants.
- Criterion.rs User Guide, divan, cargo-llvm-cov, cargo-nextest.
- Jon Gjengset, «Rust for Rustaceans» — лучший практический разбор Miri, loom и фаззинга.
- Jim Blandy, Jason Orendorff, Leonora Tindall, «Programming Rust», 2nd ed. — тесты и документация в контексте всего языка.
- Alex Kladov (matklad), «Delete Cargo Integration Tests» и «How to Test».
Что дальше
Прод на Rust: структура проекта, модули, конфигурация, ошибки в проде — соберём всё вместе: как раскладывать крейты и модули, где живёт конфигурация, как устроены ошибки и логирование в работающем сервисе и что делать, когда прод всё-таки паникует.