Перейти к основному содержимому

Тестирование в Rust

Разработчику Архитектору

Тестирование в Rust

Зачем тестировать и что уже есть "из коробки"

В Rust тестирование встроено прямо в рабочий процесс через Cargo. Отдельный фреймворк для базовых сценариев не нужен: достаточно команды cargo test. Она компилирует тестовые функции, запускает их и показывает понятный отчёт.

Практический плюс здесь очень прямой: вносите правку и сразу видите, сохранилось ли старое поведение. Так регрессии ловятся рано, пока исправление ещё дешёвое.

Чаще всего используют два уровня тестов:

УровеньГде лежит кодЧто проверяет
Unit-тестВ том же файле, модуль #[cfg(test)]Одну функцию или маленький модуль изолированно
Интеграционный тестКаталог tests/*.rsПубличный API crate "снаружи", как внешний клиент

База языка: Первая программа. HTTP-сервис из Axum. Синтаксис макросов и атрибутов — справочник.


Как Cargo находит тесты

  1. Функции с атрибутом #[test] внутри модуля с #[cfg(test)] (обычно в src/lib.rs или рядом с кодом в src/*.rs).
  2. Каждый файл tests/имя.rs — отдельный исполняемый тестовый бинарник; он видит только публичные элементы вашей библиотеки (pub fn, pub mod).

Флаг #[cfg(test)] означает: этот модуль компилируется только при cargo test, в релизную сборку он не попадает.


Первый unit-тест в том же файле

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

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

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

Разбор:

  • mod tests — вложенный модуль только для тестов; имя может быть любым.
  • use super::*; — импорт всего из родительского модуля (add и т.д.).
  • #[test] — Cargo считает функцию тестом; она должна быть fn, без параметров (кроме специальных случаев вроде #[should_panic]).
  • assert_eq!(ожидаемое, фактическое) — при несовпадении тест падает и печатает оба значения.

Запуск:

cargo test
cargo test adds -- --nocapture

Разбор:

  • cargo test компилирует и запускает все найденные тесты проекта.

  • Вторая команда фильтрует запуск по имени adds, чтобы гонять только нужный кейс.

  • -- отделяет аргументы Cargo от аргументов встроенного test runner.

  • --nocapture отключает скрытие stdout, поэтому println! из тестов виден в консоли.

  • Такой запуск ускоряет локальную отладку конкретного упавшего теста.

  • Первый вызов — все тесты проекта.

  • adds — фильтр по имени (подстрока в имени теста).

  • -- --nocapture — всё после -- уходит раннеру тестов; nocapture показывает println! из теста.


Макросы проверок (часто используемые)

МакросКогда применять
assert!(условие)Условие истинно
assert_eq!(a, b)Равенство (печатает diff для многих типов)
assert_ne!(a, b)Неравенство
assert!(result.is_ok())Для Result после проверки варианта

Пример с сообщением при падении:

assert_eq!(want, got, "add({a},{b}) ожидали {want}");

Разбор:

  • assert_eq! сравнивает ожидаемое и фактическое значения.
  • Третий параметр - кастомное сообщение, которое появится при падении теста.
  • Подстановки {a}, {b}, {want} помогают быстро понять контекст конкретного кейса.
  • Такой формат особенно полезен в table-driven тестах с большим числом входов.
  • Чем информативнее сообщение, тем быстрее находится причина регрессии.

Третий аргумент — подсказка в логе, когда тест упал.


Table-driven тесты (таблица случаев)

Идея: один тест перебирает много кортежей "вход → ожидаемый выход". Так проще добавлять граничные случаи (ноль, отрицательные числа).

#[test]
fn add_cases() {
let cases = [
(0, 0, 0),
(2, 3, 5),
(-1, 1, 0),
];
for (a, b, want) in cases {
assert_eq!(want, add(a, b), "add({a},{b})");
}
}

При падении на (2, 3, 5) вы сразу видите, какой ряд таблицы сломался.

Разбор:

  • cases — массив кортежей (a, b, ожидаемый_результат).
  • for (a, b, want) in cases перебирает все сценарии в одном тесте.
  • assert_eq!(want, add(a, b), ...) падает с понятным контекстом для конкретной пары.
  • Добавить новый кейс можно одной строкой в таблицу, без копирования теста.
  • Подход хорошо масштабируется для валидации и парсеров.

Проверка Result вместо panic:

#[test]
fn parse_ok() {
let got = parse_id("42");
assert!(got.is_ok());
assert_eq!(42, got.unwrap());
}

#[test]
fn parse_err() {
let got = parse_id("abc");
assert!(got.is_err());
}

Разбор:

  • is_ok() / is_err() проверяют вариант Result без паники.
  • unwrap() здесь допустим только после явной проверки is_ok().
  • Два отдельных теста делают happy-path и error-path независимыми.
  • Такой стиль предпочтительнее should_panic для бизнес-ошибок.
  • При падении видно, какой именно сценарий сломался.

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

Обычный #[test] запускает синхронную функцию. Код с .await требует runtime.

В Cargo.toml (зависимость только для тестов и примеров):

[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }

Разбор:

  • [dev-dependencies] подключает зависимости только для тестов/примеров, не для production-сборки.

  • tokio нужен, чтобы запускать асинхронные тесты с .await.

  • Feature rt включает runtime, а macros - атрибут #[tokio::test].

  • Такое разделение уменьшает размер графа зависимостей основного бинарника.

  • Если забыть эту секцию, async-тесты не скомпилируются.

  • rt — runtime.

  • macros — макрос #[tokio::test].

#[tokio::test]
async fn async_works() {
let result = tokio::time::timeout(
std::time::Duration::from_secs(1),
async { 42 },
)
.await
.unwrap();
assert_eq!(42, result);
}

Разбор:

  • #[tokio::test] — как #[tokio::main], но для теста: создаётся runtime, выполняется async fn.
  • timeout(..., future) — если future дольше секунды, вернётся ошибка; в тесте мы ждём успех и значение 42.

Для HTTP на Axum почти всегда нужен именно #[tokio::test], потому что запрос к приложению — асинхронная операция.


Тест HTTP-приложения (Axum)

Цель: собрать Router в памяти, отправить один HTTP-запрос без поднятия реального TCP-порта.

Зависимости в Cargo.toml:

[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

Разбор:

  • tower с feature util даёт ServiceExt и oneshot для тестирования Router без реального порта.
  • http-body-util нужен, чтобы удобно собрать body ответа в байты (collect, to_bytes).
  • tokio обеспечивает async-runtime для #[tokio::test].
  • Все три зависимости находятся в dev-dependencies, потому что нужны именно тестам.
  • Такой набор позволяет строить быстрые HTTP-тесты "в памяти", без тяжёлого e2e.

Пример (подходит для Axum 0.7 с trait Service):

Код ITЗагрузка примера кода…

Пояснения:

  • Router реализует сервис Tower; oneshot — один запрос "в лоб", без долгоживущего соединения.
  • Request::builder().uri(...) — собираем HTTP-запрос вручную.
  • into_body().collect() — читаем тело ответа в байты (для JSON используют serde_json::from_slice).

В новых версиях Axum иногда удобнее axum::test::TestClient — смотрите документацию к вашей версии crate: API слегка меняется между релизами.

Практический совет: вынесите сборку Router в функцию pub fn app() -> Router в lib.rs — и production main, и тесты вызывают одну и ту же конфигурацию маршрутов.

Тест JSON-ответа POST /notes:

Код ITЗагрузка примера кода…

Разбор:

  • test_state() готовит изолированное состояние для теста.
  • Request::builder() собирает POST с JSON-телом вручную.
  • Проверяется HTTP-код 201 Created, а не только тело.
  • serde_json::from_slice валидирует, что ответ действительно корректный JSON.
  • assert_eq!("тест", note.text) подтверждает бизнес-поле в ответе.

Интеграционные тесты в tests/

Структура:

my-app/
src/
lib.rs # pub fn run() ...
main.rs # вызывает my_app::run()
tests/
api.rs

Разбор:

  • Папка src содержит production-код приложения и библиотеки.
  • lib.rs делают основной точкой логики, чтобы её можно было импортировать в тестах.
  • main.rs оставляют тонкой оболочкой запуска, без бизнес-логики.
  • Каждый файл в tests/ компилируется как отдельный crate и тестирует публичный API снаружи.
  • Такая структура поощряет чистые интерфейсы и уменьшает неявные зависимости.

tests/api.rs:

// use my_app;

#[test]
fn integration_smoke() {
// let app = my_app::app();
// ... полный сценарий через HTTP client или lib API
assert!(true);
}

Cargo компилирует каждый файл в tests/ как отдельный crate, который зависит от вашей библиотеки как от внешнего пакета. Поэтому логику держат в lib.rs, а main.rs остаётся тонкой оболочкой — так проще тестировать (Cargo workspace).

Для "чёрного ящика" через сеть иногда поднимают сервер в тесте на случайном порту (127.0.0.1:0) и бьют в него reqwest — тяжелее, но ближе к реальности.

Шаблон lib.rs + main.rs для тестируемого API:

// src/lib.rs
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
// сборка Router, bind, serve
Ok(())
}
// src/main.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
my_app::run()
}

Разбор:

  • run() в библиотеке содержит основную логику запуска сервиса.
  • main.rs только делегирует вызов в lib.
  • Интеграционные тесты импортируют my_app как внешний crate.
  • Result в main позволяет корректно завершать процесс с кодом ошибки.
  • Такой каркас упрощает переход к workspace и CI.

Моки зависимостей — mockall

Когда функция зависит от трейта (интерфейса), в тестах подменяют реализацию фейком.

Код ITЗагрузка примера кода…

В Cargo.toml:

[dev-dependencies]
mockall = "0.13"

Разбор:

  • Подключается библиотека mockall для генерации mock-реализаций трейтов в тестах.
  • Версия 0.13 фиксирует совместимый API семейства 0.13.x.
  • Зависимость dev-only: в runtime приложения mock-код не попадает.
  • Полезна, когда нужно изолировать тестируемую логику от БД, сети и внешних сервисов.
  • Перед обновлением версии стоит прогонять весь набор тестов из-за возможных изменений API mock'ов.

#[automock] генерирует структуру MockStore с методами expect_* для настройки ожиданий. Альтернатива — написать простую ручную struct FakeStore с Vec внутри; для маленьких проектов этого достаточно.


Тесты, которые должны упасть

#[test]
#[should_panic]
fn panics_on_invalid() {
parse_empty().unwrap();
}

Разбор:

  • Атрибут #[should_panic] инвертирует ожидание: тест успешен, если внутри случилась panic.
  • Такой приём проверяет, что некорректный вход действительно приводит к аварийному завершению.
  • unwrap() специально оставлен, чтобы получить panic на ошибочном результате.
  • Лучше применять точечно, когда panic - осознанная часть контракта функции.
  • Для большинства бизнес-сценариев предпочтительнее проверять Result без panic.

Или с ожидаемым текстом паники:

#[should_panic(expected = "пустой ввод")]
fn panics_with_message() { ... }

Разбор:

  • expected = "..." дополнительно проверяет, что panic содержит нужный фрагмент текста.
  • Это защищает от ложноположительного прохождения при panic по другой причине.
  • Сообщение стоит делать стабильным и информативным, чтобы тест не был хрупким.
  • При рефакторинге текста ошибок такие тесты требуют синхронного обновления.
  • Для публичного API обычно лучше избегать завязки на конкретные panic-сообщения.

Используйте осознанно: слишком много should_panic усложняет поддержку.


CI — что обычно гоняют в pipeline

- run: cargo test --workspace
- run: cargo clippy --workspace -- -D warnings
- run: cargo fmt --check

Разбор:

  • cargo test --workspace запускает тесты во всех crate монорепозитория.

  • cargo clippy ... -D warnings превращает предупреждения линтера в ошибку пайплайна.

  • cargo fmt --check проверяет форматирование без изменения файлов.

  • Такой минимум CI ловит функциональные, стилистические и качественные проблемы до merge.

  • Порядок шагов обычно выбирают от "самое дешёвое" к "самое тяжёлое", чтобы быстрее получать фидбек.

  • --workspace — все crate монорепозитория (workspace).

  • Clippy с -D warnings — предупреждения становятся ошибкой сборки.

  • fmt --check — код отформатирован единообразно.

Для библиотек с features иногда добавляют cargo test --all-features, чтобы собрать все ветки #[cfg(feature = "...")].


Что считать хорошим покрытием

Количество процентов покрытых строк само по себе мало что гарантирует. Для практической пользы тесты закрывают разные уровни риска:

УровеньЧто проверять в первую очередь
Бизнес-правилавычисления, валидация, граничные условия
ИнтеграцииHTTP-контракты, сериализация JSON, коды ошибок
Надёжностьтаймауты, поведение при недоступной зависимости
Регрессиисценарии по реальным баг-репортам

Приоритет простой: сначала закрыть критичные пользовательские сценарии, потом расширять покрытие вокруг них.


Стратегия тестирования для Rust-проекта

  1. Много unit-тестов — быстрые, дешёвые, запускаются постоянно.
  2. Меньше интеграционных — проверяют склейку модулей и публичный API.
  3. Немного end-to-end — полный путь через сеть, БД и внешние зависимости.

Такой баланс держит CI быстрым и одновременно даёт уверенность, что система работает целиком.


Тестовые данные и фикстуры

Чтобы тесты оставались читаемыми, полезно вынести подготовку данных в helper-функции:

fn make_note(text: &str) -> Note {
Note { id: 1, text: text.to_string() }
}

Разбор:

  • Helper-функция централизует создание тестовой сущности Note.
  • Параметр text: &str позволяет быстро задавать разные входные данные в тестах.
  • text.to_string() переводит строковый срез в владеющий String, как требует структура.
  • Повторяющийся id: 1 упрощает фокус на тестируемом поле, когда id не важен для сценария.
  • Такой подход уменьшает дублирование и повышает читаемость тестов.

Практика:

  • использовать осмысленные значения вместо случайных строк;
  • отделять "подготовку" от "проверки";
  • хранить повторяющиеся JSON-образцы в tests/fixtures/.

Это упрощает поддержку, когда API растёт.


Анти-паттерны в тестах

  • Слишком много логики в тесте: тест превращается во второй прод-код.
  • Нестабильные sleep: лучше timeout и ожидание конкретного условия.
  • Общие mutable-глобалы: тесты становятся зависимыми от порядка запуска.
  • Проверка только happy-path: баги чаще живут в ошибках и краях.

Для асинхронных сервисов из Axum особенно важно проверять коды ошибок и таймауты, а не только 200 OK.


Связанные материалы


Основа по протоколу

Базовый разбор HTTP и HTTPS находится в отдельной статье — HTTP как основа веб-интеграций.