Тестирование в Rust
Тестирование в Rust
Зачем тестировать и что уже есть "из коробки"
В Rust тестирование встроено прямо в рабочий процесс через Cargo. Отдельный фреймворк для базовых сценариев не нужен: достаточно команды cargo test. Она компилирует тестовые функции, запускает их и показывает понятный отчёт.
Практический плюс здесь очень прямой: вносите правку и сразу видите, сохранилось ли старое поведение. Так регрессии ловятся рано, пока исправление ещё дешёвое.
Чаще всего используют два уровня тестов:
| Уровень | Где лежит код | Что проверяет |
|---|---|---|
| Unit-тест | В том же файле, модуль #[cfg(test)] | Одну функцию или маленький модуль изолированно |
| Интеграционный тест | Каталог tests/*.rs | Публичный API crate "снаружи", как внешний клиент |
База языка: Первая программа. HTTP-сервис из Axum. Синтаксис макросов и атрибутов — справочник.
Как Cargo находит тесты
- Функции с атрибутом
#[test]внутри модуля с#[cfg(test)](обычно вsrc/lib.rsили рядом с кодом вsrc/*.rs). - Каждый файл
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с featureutilдаёт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-проекта
- Много unit-тестов — быстрые, дешёвые, запускаются постоянно.
- Меньше интеграционных — проверяют склейку модулей и публичный API.
- Немного 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 как основа веб-интеграций.