В Rust тесты — часть языка и Cargo, а не отдельный фреймворк. Модульные (unit) тесты обычно располагаются прямо в файле рядом с тестируемым кодом, во вложенном модуле tests, помеченном #[cfg(test)] — это гарантирует, что тестовый код не попадёт в релизный бинарник.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_positive_numbers() {
assert_eq!(add(2, 3), 5);
}
#[test]
#[should_panic(expected = "overflow")]
fn panics_on_overflow() {
let _ = i32::MAX.checked_add(1).expect("overflow");
}
#[test]
fn returns_result() -> Result<(), String> {
if add(1, 1) == 2 {
Ok(())
} else {
Err("math is broken".to_string())
}
}
}
Тест считается провалившимся, если функция паникует, либо (для теста, возвращающего Result) возвращает Err. Второй вариант удобен, когда внутри теста используется оператор ? вместо цепочки unwrap().
cargo test собирает крейт дважды — обычную библиотеку/бинарник и отдельный тестовый харнесс с cfg(test), включённым только во второй сборке. Поэтому тестовый код не увеличивает размер продакшен-бинарника.
Интеграционные тесты живут в отдельной директории tests/ на уровне крейта — каждый файл там компилируется как отдельный крейт и видит только публичный API вашей библиотеки, как внешний потребитель.
// tests/api_integration.rs
use my_crate::{Client, Config};
#[test]
fn client_connects_with_default_config() {
let client = Client::new(Config::default());
assert!(client.is_ready());
}
| Критерий | Unit-тесты (src/, cfg(test)) | Integration-тесты (tests/) |
|---|---|---|
| Доступ к коду | к приватным полям и функциям модуля | только к публичному API крейта |
| Скорость сборки | компилируются вместе с крейтом | каждый файл — отдельный крейт, компилируется дольше |
| Назначение | проверка отдельной функции/структуры | проверка поведения крейта "снаружи" |
| Общий код | соседние функции модуля | вынесите в tests/common/mod.rs |
Общий вспомогательный код для интеграционных тестов кладут в tests/common/mod.rs — именно mod.rs, а не common.rs, чтобы Cargo не считал этот файл отдельным тестовым крейтом.
Примеры кода в doc-комментариях (///) компилируются и выполняются как тесты командой cargo test. Это гарантирует, что документация никогда не "врёт" — устаревший пример просто сломает сборку.
/// Складывает два числа.
///
/// # Examples
///
/// ```
/// use my_crate::add;
///
/// assert_eq!(add(2, 2), 4);
/// ```
///
/// # Panics
///
/// Паникует при переполнении в debug-режиме:
///
/// ```should_panic
/// # use my_crate::add;
/// add(i32::MAX, 1);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Строки, начинающиеся с # внутри блока кода, скрываются в отрендеренной документации, но по-прежнему компилируются — удобно прятать вспомогательный boilerplate (импорты, setup). Атрибут ```ignore``` исключает пример из выполнения, ```no_run``` — компилирует, но не запускает.
Вместо того чтобы вручную придумывать тестовые случаи, property-based тестирование генерирует сотни случайных входов и проверяет, что некоторое свойство (инвариант) выполняется всегда. Библиотека proptest — стандартный выбор в экосистеме Rust (аналог QuickCheck из Haskell).
use proptest::prelude::*;
fn reverse<T: Clone>(input: &[T]) -> Vec<T> {
let mut v = input.to_vec();
v.reverse();
v
}
proptest! {
#[test]
fn double_reverse_is_identity(xs in proptest::collection::vec(any::<i32>(), 0..100)) {
let reversed_twice = reverse(&reverse(&xs));
prop_assert_eq!(reversed_twice, xs);
}
#[test]
fn parse_never_panics(s in ".*") {
let _ = s.parse::<i64>(); // не должно паниковать ни при каком вводе
}
}
При падении теста proptest автоматически выполняет shrinking — минимизирует найденный контрпример до самого маленького, на котором свойство всё ещё нарушается, что сильно упрощает отладку.
В Rust нет встроенной поддержки моков в стиле Mockito/Moq — вместо перехвата вызовов на рантайме идиоматичный подход опирается на трейты и внедрение зависимостей: код зависит от трейта, а в тестах подставляется тестовая реализация.
trait PaymentGateway {
fn charge(&self, cents: u64) -> Result<String, String>;
}
struct OrderService<G: PaymentGateway> {
gateway: G,
}
impl<G: PaymentGateway> OrderService<G> {
fn checkout(&self, cents: u64) -> Result<String, String> {
self.gateway.charge(cents)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockGateway { should_fail: bool }
impl PaymentGateway for MockGateway {
fn charge(&self, _cents: u64) -> Result<String, String> {
if self.should_fail {
Err("declined".into())
} else {
Ok("tx_123".into())
}
}
}
#[test]
fn checkout_fails_when_gateway_declines() {
let service = OrderService { gateway: MockGateway { should_fail: true } };
assert!(service.checkout(500).is_err());
}
}
Для сложных сценариев с записью и проверкой вызовов используют крейты mockall (генерирует моки через derive-подобный proc-macro #[automock]) или ручные fake-объекты со счётчиками вызовов на RefCell/Cell.
OrderService<G: PaymentGateway>) вместо dyn PaymentGateway в горячем пути — мономорфизация даёт статическую диспетчеризацию без потери тестируемости, а dyn Trait оставляйте для мест, где нужна рантайм-полиморфность (плагины, коллекции разнородных объектов).
Встроенный #[bench] доступен только в nightly Rust, поэтому стандартом де-факто стал крейт criterion — он работает на stable, использует статистический анализ (доверительные интервалы, обнаружение выбросов) и строит графики регрессий между запусками.
// benches/fib_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn fibonacci(n: u64) -> u64 {
match n {
0 => 1,
1 => 1,
n => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn bench_fibonacci(c: &mut Criterion) {
c.bench_function("fib 20", |b| {
b.iter(|| fibonacci(black_box(20)))
});
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
black_box запрещает компилятору "вычислить результат заранее" или выкинуть код как мёртвый — без него агрессивный инлайнинг и constant folding могут сделать бенчмарк бессмысленным. Запуск: cargo bench, результаты сохраняются в target/criterion/ с HTML-отчётами.
Fuzzing генерирует случайные (или мутированные из корпуса) байтовые последовательности и скармливает их функции в поисках паники, паники по переполнению или UB под Miri/ASan. Инструмент cargo-fuzz оборачивает LLVM's libFuzzer.
// fuzz/fuzz_targets/parse.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = my_crate::parse_config(s); // не должно паниковать/UB на любом вводе
}
});
$ cargo install cargo-fuzz
$ cargo fuzz init
$ cargo fuzz run parse
Fuzzing особенно ценен для парсеров, десериализаторов и любого кода, работающего с недоверенным вводом — именно там чаще всего прячутся паники на edge-case и index-out-of-bounds, которые не приходят в голову при ручном написании тестов.
#[test]-функций.tests, а не дублируются.cargo test -- --ignored.#[test]
#[ignore = "требует запущенной БД"]
fn integration_with_real_database() {
// ...
}
cargo test запускает тесты в разных потоках одного процесса. Тесты, читающие/пишущие переменные окружения, временные файлы с фиксированным именем или слушающие один и тот же порт, будут "мигать" (flaky) без явной синхронизации или изоляции (например, уникальные имена файлов, tempfile, отдельные порты через 0).