Тестирование в Rust: cargo test, proptest, mockall, Testcontainers

Тестирование Rust-сервиса на живом стенде: юниты рядом с кодом против чёрного ящика в tests/, doc-тесты как проверяемая документация и их реальная цена, property-based через proptest, mockall против фейка и Postgres в контейнере

Тестирование в Rust встроено в cargo и в сам язык: #[test], cargo test, doc-тесты, которые проверяют примеры в документации. Но интереснее не список инструментов, а то, что часть тестов здесь вообще не пишется — компилятор забирает их себе. Причём это можно проверить: в Rust есть тест, который зелёный ровно тогда, когда код не собирается.

Это статья серии «Rust: глубокое погружение». Всё ниже — с живого стенда rust/testing: один домен, шесть приёмов, все числа сняты, а не взяты из головы. Домен намеренно тот же, что в кросс-языковом стенде (Go, Java, Python) — чтобы Rust можно было сравнить с соседями на одной задаче, а не на разных.

Ретрофутуристская схема в стиле «Полдень»: двухступенчатая линия контроля слева направо. Сначала стальные ворота-шаблон с фигурным вырезом: деталь верной формы проходит насквозь, неподходящие отклоняются в терракотовый ящик для брака. Сразу за ними вторые такие же ворота — в них застряла деталь-переросток, которую первые пропустили. Справа испытательный стенд, куда попадает только прошедшее обе ступени: индикаторная головка, штангенциркуль и бункер, сыплющий на стол десятки одинаковых образцов. Над стендом в рамке — гравированный эталон детали, соединённый пунктиром с образцом внизу: деталь обязана ему соответствовать

В статье

Чего в тестах нет и почему

Начнём с домена — объёмная скидка по тирам, суммы в копейках. В Go или Python первый же тест был бы «а что если сумма отрицательная». Здесь его нет:

/// Сумма в копейках. Неотрицательна ПО ПОСТРОЕНИЮ.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Money(i64);

impl Money {
    /// Единственный вход. Отрицательное значение сюда не пройдёт.
    pub fn try_new(cents: i64) -> Result<Self, MoneyError> {
        if cents < 0 {
            return Err(MoneyError::Negative(cents));
        }
        Ok(Money(cents))
    }
}

Поле приватное, конструктор один. Отрицательную Money не построить — не потому, что мы её проверяем в каждой функции, а потому, что её неоткуда взять. Тест «а что если сумма отрицательная» писать не на что.

А теперь то, на чём я сам и попался. В первой версии стенда Money отвергал только отрицательные, и я написал в расчёте скидки вот это:

Money::try_new(d).expect("скидка неотрицательна по построению")

Рассуждение выглядело железным: d ∈ [0, c] по арифметике, c >= 0 по типу — значит expect не выстрелит. Ревьюер попросил проверить. Проверяю:

thread 'discount_на_большой_сумме' panicked at src/pricing.rs:23:9:
attempt to multiply with overflow

Money::try_new(i64::MAX) возвращал Ok — тип-то запрещал только знак. А внутри discount стоит c * 10 / 100, и на девятнадцатизначной сумме умножение переполняет i64. В отладочной сборке это паника прямо на умножении, в релизной — заворот в отрицательное, и тогда паникует уже сам expect. Моё «d ∈ [0, c] по арифметике» оказалось ложным, а property-тест этого не поймал ровно потому, что я сам обрезал ему генератор на миллиарде.

Вывод неприятный и полезный: тип снял знак, но не диапазон. Пока диапазон не выражен, «неверное состояние непредставимо» — фигура речи, а не свойство кода. Лечится тем же способом, каким снимали знак, — доводим тип до настоящего инварианта домена:

impl Money {
    /// Максимум домена: 10 миллионов рублей.
    pub const MAX_CENTS: i64 = 1_000_000_000;

    pub fn try_new(cents: i64) -> Result<Self, MoneyError> {
        if cents < 0 {
            return Err(MoneyError::Negative(cents));
        }
        if cents > Self::MAX_CENTS {
            return Err(MoneyError::TooLarge(cents));
        }
        Ok(Money(cents))
    }
}

Вот теперь c <= 10^9, значит c * 10 <= 10^10, а до i64::MAX (~9.2·10¹⁸) далеко — и рассуждение про безопасность expect стало доказуемым вместо декларативного. Заодно в стенде остался регрессионный тест на эту историю.

Мораль не в том, что типы не работают, а в том, что они работают ровно настолько, насколько вы потрудились. «Компилятор заберёт эти тесты себе» — не подарок, а сделка: он забирает ровно те проверки, которые вы выразили в типе, и ни одной сверх.

Но у такого подхода есть второе слабое место: он держится на том, что поле осталось приватным. Сделай его однажды pub — и дыра открылась молча. Здесь у Rust есть приём, который стоит три строки, — doc-тест, проверяющий, что код НЕ компилируется:

/// А вот так — не скомпилируется вовсе: поле приватное, и обойти конструктор
/// нельзя. Этот doc-тест ПРОВЕРЯЕТ, что дыры нет:
///
/// ```compile_fail
/// use rust_testing::money::Money;
/// let m = Money(-500); // поле приватное → ошибка компиляции
/// ```

В прогоне он выглядит как обычный тест:

test src/money.rs - money::Money (line 42) - compile fail ... ok

Уникально тут не само наличие такой проверки — статически компилируемые соседи умеют не меньше. В Java для этого берут Google compile-testing (assertThat(source).failsToCompile()), в Go собирают код компилятором из теста, в Python гоняют mypy отдельным прогоном. Уникально то, что в Rust это встроено в штатный тест-раннер, без единой зависимости, и живёт прямо в документации: три строки в ///, и cargo test их подхватит.

И сразу честная граница приёма: compile_fail зелёный при любой ошибке компиляции сниппета. Переименуете модуль, уберёте pub с самого Money, опечатаетесь в use — тест останется зелёным, хотя проверять он уже перестал. Он утверждает «не собирается», а не «не собирается по нашей причине». Нужна причина — берите trybuild, он сверяет текст ошибки с эталоном.

Юниты рядом с кодом и чёрный ящик в tests/

В Rust два места для тестов, и разница между ними не стилистическая, а физическая.

#[cfg(test)] mod tests рядом с кодом — видит приватное:

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

    #[test]
    fn видим_приватное_поле() {
        // Внутри модуля это законно. Из tests/ — ошибка компиляции.
        let m = Money::try_new(500).unwrap();
        assert_eq!(m.0, 500);
    }
}

Файл в tests/ — отдельный крейт. Он подключает библиотеку ровно так же, как это сделает чужой код: только через pub. Та же строка там не соберётся:

error[E0616]: field `0` is private

Это и есть смысл папки: что нельзя проверить снаружи — то и не является контрактом крейта. Табличный тест, кстати, в Rust не требует ни фреймворка, ни макросов — обычный цикл по массиву:

let cases = [
    (9_999, 0, "ниже порога"),
    (10_000, 500, "ровно 100.00 — 5%"),
    (19_999, 999, "на копейку ниже 200 — 5% с усечением"),
    (20_000, 2_000, "ровно 200.00 — 10%"),
    (25_000, 2_500, "250.00 — 10%"),
    (0, 0, "ноль"),
];
for (total, want, name) in cases {
    assert_eq!(discount(m(total)).cents(), want, "случай: {name}");
}

Грань, на которую легко напороться. Из этого разделения следует неочевидное: mockall под #[cfg_attr(test, mockall::automock)] не виден интеграционным тестам. Атрибут раскрывается, только когда крейт собирают с cfg(test), то есть для юнитов. Тест из tests/ собирает библиотеку как обычную зависимость — без cfg(test), — и моков там просто нет:

error[E0432]: unresolved import `rust_testing::service::MockStore`

Это не баг mockall, а прямое следствие того, что tests/ — отдельный крейт. Моки трейтов — инструмент юнит-теста; нужны снаружи — выносите генерацию под отдельную feature.

Doc-тесты: документация, которая не врёт

Примеры в /// компилируются и запускаются вместе с тестами:

/// ```
/// use rust_testing::{money::Money, pricing::price};
/// // 250.00 минус 10%
/// assert_eq!(price(Money::try_new(25_000).unwrap()).cents(), 22_500);
/// ```
pub fn price(total: Money) -> Money {  }

Ценность не в том, что это «ещё одни тесты». Ценность в том, что документация не может устареть молча: соврал в примере — упал cargo test. Обычные комментарии гниют, эти — нет.

У этого есть цена, и она устроена не так, как у остальных тестов. Обычные тесты компилируются вместе с крейтом, и target/ кеширует результат: со второго прогона вы платите только за исполнение. Doc-тесты собирает rustdoc, и на каждом прогоне заново — кеша у них нет. Поэтому на прогретом стенде они и оказываются самым долгим набором:

набор тестов раннер
юниты (--lib) 10 0.00 с
tests/black_box.rs 3 0.00 с
property (инвариант) 1 0.01 с
doc-тесты 5 0.09 с

Только вот «самый долгий» здесь — это девять сотых секунды: весь прогретый cargo test укладывается в 0.28 с (медиана пяти прогонов). Первый же прогон после git clone34 секунды, и это компиляция зависимостей, к doc-тестам отношения не имеющая. Так что на этом масштабе платить не за что.

Механизм, за который платят, виден, если довести число примеров до правдоподобного для живой библиотеки. На edition 2021 каждый doc-тест компилируется отдельным бинарником, и цена растёт линейно — около 10 мс за пример. Начиная с 1.85 rustdoc на edition 2024 сливает совместимые doc-тесты в один бинарник, и кривая становится плоской (несливаемыми остаются особенные — с compile_fail, со своим fn main):

doc-тестов edition 2021 edition 2024
10 0.16 с 0.08 с
35 0.38 с 0.07 с
110 1.36 с 0.08 с

Это не разовый замер, а скрипт measure-doctests.sh в стенде: он генерирует нужное число тривиальных doc-тестов, прогоняет на обеих edition и печатает таблицу — на вашем тулчейне числа будут свои, но форма (2021 линейно, 2024 плоско) сохранится.

Вывод скучнее, чем хотелось бы: на публичном API пишите не задумываясь — пример в документации первым читает потребитель, и пусть лучше он падает у вас в CI, чем врёт у него в редакторе. Секунда на сотню примеров — не та цена, ради которой стоит отказываться от документации, которая не врёт. Если она всё же мешает — это аргумент за edition 2024, а не против doc-тестов.

Где сотые доли секунды превращаются в секунды. Ровно те же пять doc-тестов, тот же тулчейн: 0.09 с в нативной ФС и несколько секунд, если собирать в примонтированной виндовой папке (/mnt/… из WSL). Множитель машинозависимый — у нас от прогона к прогону выходило ×70…80, а на менее удачной конфигурации ревьюер намерил все 24 секунды, — но порядок один: drvfs дороже на порядки. По doc-тестам это бьёт сильнее всего именно потому, что они компилируются на каждом прогоне: остальные наборы прячутся за кешем target/, а этим каждый раз нужен настоящий ввод-вывод. Тот же measure-doctests.sh из /mnt/… покажет обе цифры рядом.

Первым на этих числах попался автор. В черновике статьи стояло «7.12 с» и вывод «doc-тесты съедают больше половины прогона» — цифра честно снята с раннера, но мерила она не цену doc-тестов, а цену drvfs. Оба вывода пришлось выбросить. Мораль двойная: собирайте Rust в нативной ФС — и не приписывайте замеру причину, которую в нём не проверяли.

Property-based: proptest

Идея та же, что в любом языке: генератор случаев, инвариант, шринкинг. В Rust она ложится на типы особенно удобно — половину инварианта часто уже держит система типов.

proptest! {
    #[test]
    fn скидка_не_больше_суммы(cents in 0i64..1_000_000_000) {
        let total = m(cents);
        let p = price(total);
        prop_assert!(p.cents() <= total.cents(),
            "цена {} больше суммы {}", p.cents(), total.cents());
    }
}

Обратите внимание: «цена не отрицательна» тут не проверяется. Это гарантирует Money, и написать такой тест можно было бы разве что ради самоуспокоения.

Теперь свойство поинтереснее: заказ на большую сумму не может стоить дешевле. Звучит как очевидная истина, но у тиров скачок — на 99.99 скидки нет (платим 99.99), на 100.00 сразу 5% (платим 95.00). И вот что важно: это не баг в коде. Код точно соответствует спецификации — той самой таблице тиров. Немонотонна сама спецификация, и property-тест ищет дыру в постановке, а не отклонение от неё.

Наивный генератор 0..30_000 находит её так:

test монотонность_наивный ... FAILED
Test failed: заказ на 18948 стоит 18001, а на 20000 — 18000: заплатить больше оказалось дешевле
minimal failing input: a = 20000, b = 18948

Пара 18948 / 20000 стоит вплотную к порогу второго тира. Ни один человек не вписал бы в табличный тест число 18948, а шринкер вписал — и показал границу, на которой ломается спецификация. Только «минимальность» тут локальная: в разных прогонах выходят разные пары — 20000/18948, 19500/20000, 19059/20000. Шринкер ужимает найденное, а не ищет глобально наименьшее.

Только «находит» — слишком сильное слово. Двадцать прогонов с очисткой кэша находок дают 7 из 20: на дефолтных 256 случаях (это cases: 256 в default_default_config() у proptest 1.11) свойство ловит реальное нарушение примерно в трети прогонов — с очень широким разбросом, к которому вернёмся через абзац. Для сравнения — тот же домен у соседей: rapid (дефолт Go, 100 примеров) — 0 из 20, Hypothesis (дефолт Python, 100) — 0 из 20, jqwik (дефолт Java, 1000 попыток) — 5 из 40 (там прогонов было сорок).

Точечно proptest выглядит лучше всех, и хочется это заявить. Нельзя — и главная причина даже не статистическая: это сравнение дефолтов, а не движков. 256 против 100 против 1000 — разные бюджеты, и что тут вклад стратегии генерации, а что просто «дали больше попыток», по одному свойству на одном домене не разделить. Вторая причина — короткая серия: 7 из 20 это 35% с интервалом [15.4%, 59.2%], 0 из 20 — [0%, 16.8%], и двадцати прогонов мало, чтобы уверенно различать что бы то ни было.

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

Лечится не «побольше примеров», а генератором, который знает структуру домена — что у скидки есть пороги, — но не знает про само нарушение:

a in prop_oneof![
    0i64..30_000,
    (TIER1_CENTS-500)..(TIER1_CENTS+500),
    (TIER2_CENTS-500)..(TIER2_CENTS+500)
],

С ним падает стабильно. Ответ мы движку не подсказали — сообщили факт из спецификации; нарушение по-прежнему находит свойство, а не человек.

И ещё одна деталь, на которой легко обмануть себя при замерах: proptest складывает найденные контрпримеры в tests/*.proptest-regressions и переигрывает их первыми. В работе это отлично — упавший пример не потеряется. При измерении «а находит ли заново» файл надо удалять, иначе вы меряете не поиск, а память.

Дублёры: mockall и фейк

Границы в Rust — это трейты, и mockall делает из трейта мок одной строкой:

#[cfg_attr(test, mockall::automock)]
pub trait Store {
    fn save(&mut self, order: &Order) -> Result<(), ServiceError>;
    fn by_user(&self, user_id: &str) -> Vec<Order>;
}

Проверим дублёров не определениями, а делом: под флагом bug сервис теряет скидку — клиент платит полную сумму.

// СЛАБАЯ проверка: save позвали один раз
store.expect_save().times(1).returning(|_| Ok(()));

// СИЛЬНАЯ: смотрим, ЧТО понесли в save
store.expect_save()
     .withf(|o: &Order| o.price.cents() == 9_500)
     .times(1)
     .returning(|_| Ok(()));

Результат:

test на_фейке_заказ_сохранён_со_скидкой ... FAILED
test на_моке_слабая_проверка_только_число_вызовов ... ok
test на_моке_сильная_проверка_смотрим_аргумент ... FAILED

Напрашивается «фейк умеет, мок не умеет» — и это неверно. Мок с withf ловит тот же дефект. Пропускает не мок, а слабое утверждение: «save позвали один раз» — осмысленная проверка (так ловят потерянную или задублированную запись), просто про цену она не знает, а сломалась цена.

Разница фейка и мока не в силе, а в предмете: фейк утверждает про результат, мок — про вызов. Отсюда и привязка: тест на вызовах связан с протоколом взаимодействия, тест на фейке — с наблюдаемым результатом.

Rust-специфика тут в экономике. Фейк-хранилище — десяток строк:

#[derive(Default)]
struct FakeStore {
    orders: Vec<Order>,
}

impl Store for FakeStore {
    fn save(&mut self, order: &Order) -> Result<(), ServiceError> {
        self.orders.push(order.clone());
        Ok(())
    }
    fn by_user(&self, user_id: &str) -> Vec<Order> {
        self.orders.iter().filter(|o| o.user_id == user_id).cloned().collect()
    }
}

Трейт-границы делают ручной фейк дешёвым, поэтому mockall здесь берут не «чтобы не писать фейк», а когда надо утверждать про сам вызов — например, что уведомление при ошибке хранилища не ушло (expect_notify().never()).

Настоящие зависимости и бенчи

Дублёр не знает того, чего не знает его автор. FakeStore на Vec радостно примет второй заказ с тем же ID — а настоящий Postgres ответит нарушением первичного ключа. В какой-то момент нужен не дублёр, а зависимость:

let container = GenericImage::new("postgres", "18.1-alpine")
    .with_wait_for(WaitFor::message_on_stderr(
        "database system is ready to accept connections",
    ))
    .with_env_var("POSTGRES_USER", "test")
    .with_env_var("POSTGRES_PASSWORD", "test")
    .with_env_var("POSTGRES_DB", "orders")
    .start()
    .await
    .expect("старт контейнера Postgres");

Тест помечен #[ignore] — он требует Docker и стоит секунды, а cargo test должен оставаться быстрым:

cargo test --test integration_pg -- --ignored

testcontainers-rs 0.27.3 с Docker 29.6.1 завёлся из коробки: около 3.1 секунды на прогон, ни одного флага.

Отмечаю это потому, что на этом же демоне соседний стенд повёл себя иначе. Проверить механизм можно не на слово, а своей машиной — спросите демон, что он принимает:

docker version --format '{{.Server.Version}} API {{.Server.APIVersion}}, min {{.Server.MinAPIVersion}}'
# 29.6.1 API 1.55, min 1.40

Всё, что ниже 1.40, Docker 29 больше не принимает. Java-клиент Testcontainers 1.21.3 тянет docker-java, который по умолчанию говорит на 1.32, — и на старте падает с client version 1.32 is too old. Лечится флагом -Dapi.version=1.40; он стоит в argLine соседнего стенда — там же лежит и разбор, почему это безопасно. Go-, Python- и Rust-клиенты в тех же условиях вопросов не вызвали. Весь тот стенд с этим флагом разбирает «Тестирование в разных языках».

Это наблюдение с двух конкретных стендов на одном демоне, а не приговор экосистеме: у вас другой Docker или другая версия клиента — и картина другая. Общий тут только принцип: идея Testcontainers одинакова везде, а вот насколько клиент поспевает за версией демона, выясняется только запуском — на той версии демона, которая у вас.

Про бенчи — коротко и честно. Стандартный инструмент здесь criterion: cargo bench, статистические прогоны, отчёт с доверительными интервалами. Но бенчмарк — не тест: он ничего не утверждает, он измеряет. Смешивать их в одном прогоне не стоит, и на этом стенде бенчей нет — они заслуживают отдельного разговора вместе с профилированием.

Демо и версии

Стенд: rust/testing — шесть приёмов на одном домене, каждое демо запускается одной командой.

cargo test                                        # юниты + tests/ + doc-тесты
cargo test --lib --features bug                   # демо: кто поймает потерянную скидку
cargo test --features prop_demo --test property   # демо: наивный vs доменный генератор
cargo test --test integration_pg -- --ignored     # Testcontainers + Postgres
Rust 1.97.1 (актуальный stable, релиз 16.07.2026), edition 2021; поведение проверено и на 1.96.1 — форма та же, абсолютные числа свои
proptest 1.11.0
mockall 0.15
testcontainers 0.27.3
tokio 1.52, tokio-postgres 0.7
Postgres 18.1-alpine
Docker 29.6.1 (API 1.55, min 1.40)

Замеры сняты на Windows 11 + WSL2 в нативной ФС (см. врезку про drvfs выше); абсолютные секунды у вас будут другими — важны соотношения. Числа по proptest — 20 прогонов с очисткой tests/*.proptest-regressions перед каждым; это одно свойство на одном домене, а не бенчмарк библиотек. cargo clippy --all-targets чист, в том числе с фичами bug и prop_demo.

Стенд не пинит тулчейн — cargo test возьмёт ваш stable. Нижняя граница задана зависимостями, а не статьёй: 1.88 (столько просит testcontainers 0.27.3; proptest — 1.85). Ничего из свежих релизов статья не использует, так что на более новом Rust числа сдвинутся, а выводы — нет.

Смежное на сайте: Тестирование в разных языках — тот же домен в Go, Java и Python; Как писать тестируемый кодготовится, с 22 сентября — про швы и границы вообще; Flaky-тесты; Асинхронный Rust: tokio — про #[tokio::test] и асинхронные тесты подробнее.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии