Тестирование в Rust: моки, тесты свойств и бенчмарки
Ты уже умеешь писать тест с #[test] и assert_eq! (если нет, начни с урока Тестирование). Для небольших программ этого хватает. Но в настоящих проектах быстро появляются новые вопросы. Где должны лежать тесты? Как протестировать код, который отправляет письма или смотрит на часы? Как найти тот единственный ввод, о котором ты не подумал? И правда ли твоя «более быстрая» версия быстрее?
Представь автомастерскую. Базовые тесты проверяют, что каждая деталь работает. Эта страница добавляет всё остальное оборудование: стенд для деталей (фейки и моки), машину, которая трясёт автомобиль тысячами случайных толчков (тесты свойств), фотографию того, как всё выглядело в прошлый раз (снапшоты), и секундомер, которому можно верить (бенчмарки).
Одно честное замечание перед началом: кнопка Run в Rust Playground запускает main, а не тесты, и крейтов для тестирования, которые используются ниже, там нет. Поэтому большинство примеров здесь показывают настоящий вывод cargo test и cargo bench из небольшого проекта на обычном компьютере. Если у программы есть main, как обычно, будет кнопка «Попробуй сам».
Как организовать тесты
Заголовок раздела «Как организовать тесты»Вот структура маленькой библиотеки shop, которую мы используем на этой странице:
shop/├── Cargo.toml├── src/│ ├── lib.rs <- код + модульные тесты в `mod tests`│ └── orders.rs├── tests/│ ├── checkout.rs <- интеграционный тест│ └── common/│ └── mod.rs <- помощники, общие для интеграционных тестов└── benches/ └── stock.rs <- бенчмаркВ Rust есть три вида тестов, и cargo test запускает их все:
- Модульные тесты (unit tests) живут рядом с кодом, в блоке
#[cfg(test)] mod tests. Им видны и приватные функции. - Интеграционные тесты (integration tests) живут в папке
tests/. Каждый файл компилируется как отдельный крейт, который пользуется твоей библиотекой снаружи, в точности как пользователь. Поэтому они могут вызывать толькоpub-элементы. - Doc-тесты — это примеры кода внутри комментариев
///. Они доказывают, что документация всё ещё работает. Подробно о них — в уроке Документация.
Вот часть src/lib.rs. Обрати внимание на doc-тест у apply_discount, а также на функцию-помощника и фикстуру внутри mod tests:
/// Одна строка в корзине покупок. Цены — в центах.#[derive(Debug, Clone, PartialEq)]pub struct Item { pub name: String, pub price_cents: u32, pub qty: u32,}
pub fn cart_total(items: &[Item]) -> u32 { items.iter().map(|i| i.price_cents * i.qty).sum()}
/// Снижает цену на `percent` процентов.////// ```/// use shop::apply_discount;////// assert_eq!(apply_discount(1000, 25), 750);/// ```pub fn apply_discount(price_cents: u32, percent: u32) -> u32 { assert!(percent <= 100, "discount must be at most 100%, got {percent}%"); price_cents - price_cents * percent / 100}
/// Превращает "12.50" в 1250 центов.pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// Превращает 1250 центов в "12.50".pub fn format_price(cents: u32) -> String { format!("{}.{:02}", cents / 100, cents % 100)}
#[cfg(test)]mod tests { use super::*;
// Помощник: его коротко вызывать, и все тесты создают товары одинаково. fn item(name: &str, price_cents: u32, qty: u32) -> Item { Item { name: name.to_string(), price_cents, qty } }
// Фикстура: одна реалистичная корзина, общая для нескольких тестов. fn sample_cart() -> Vec<Item> { vec![item("coffee", 450, 2), item("bagel", 300, 1)] }
#[test] fn total_of_sample_cart() { assert_eq!(cart_total(&sample_cart()), 1200); }
#[test] fn empty_cart_costs_nothing() { assert_eq!(cart_total(&[]), 0); }
#[test] #[should_panic(expected = "at most 100%")] fn discount_over_100_panics() { apply_discount(1000, 150); }
// Тест может возвращать Result, тогда внутри можно использовать `?`. #[test] fn parses_a_price() -> Result<(), String> { let cents = parse_price("12.50")?; assert_eq!(cents, 1250); Ok(()) }
#[test] #[ignore = "slow: checks ten million prices"] fn every_price_round_trips() { for cents in 0..10_000_000 { assert_eq!(parse_price(&format_price(cents)), Ok(cents)); } }}Что здесь стоит заметить:
- Помощники и фикстуры — это просто функции. Особой системы фикстур в Rust нет, и скучать по ней почти не приходится. Раз они стоят внутри
#[cfg(test)], в настоящую программу они никогда не попадут. #[should_panic(expected = "...")]проходит, только если сообщение паники содержит этот текст, так что паника из-за другой ошибки не засчитывается. В уроке Тестирование об этом больше примеров.- Тесты, которые возвращают
Result, позволяют использовать?. Если тест вернулErr, он падает и выводит ошибку, напримерError: "missing '.' in \"12,50\"". #[ignore]по умолчанию пропускает медленный или особый тест. Текст после=— это причина, иcargo testеё выводит.
Интеграционный тест пользуется библиотекой через её публичный API. Его общий помощник лежит в tests/common/mod.rs:
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> { vec![ Item { name: "coffee".into(), price_cents: 450, qty: 2 }, Item { name: "bagel".into(), price_cents: 300, qty: 1 }, ]}mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]fn discounted_breakfast_total() { let total = cart_total(&common::breakfast_cart()); let to_pay = apply_discount(total, 10); println!("to pay: {}", format_price(to_pay)); assert_eq!(format_price(to_pay), "10.80");}cargo test показывает по разделу на каждый вид тестов:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 teststest tests::every_price_round_trips ... ignored, slow: checks ten million pricestest tests::parses_a_price ... oktest tests::empty_cart_costs_nothing ... oktest tests::total_of_sample_cart ... oktest tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 testtest discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 testtest src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sСначала идут модульные тесты (один пропущен — с причиной из #[ignore]), потом интеграционный тест из tests/checkout.rs, а в конце doc-тесты.
Какие тесты запускать
Заголовок раздела «Какие тесты запускать»В повседневной работе тебе не всегда нужны все тесты. Помогут эти команды. Всё, что стоит после одиночного --, передаётся самой тестовой программе, а не Cargo.
cargo test discount # только тесты, в имени которых есть "discount"cargo test --test checkout # только файл tests/checkout.rscargo test --lib # только модульные тесты в src/cargo test --doc # только doc-тестыcargo test -- --ignored # только тесты с #[ignore]cargo test -- --include-ignored # всё, включая пропускаемыеcargo test -- --nocapture # показать вывод println! из прошедших тестовcargo test -- --test-threads=1 # запускать тесты по одномуФильтр ищет часть полного имени теста во всех тестовых файлах. Здесь discount находит один модульный и один интеграционный тест:
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 testtest tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 testtest discounted_breakfast_total ... okМедленный тест запускается, только когда ты об этом просишь. Десять миллионов проходов туда и обратно заняли здесь около 3 секунд:
running 1 testtest tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20sПо умолчанию Rust прячет вывод println! из прошедших тестов, чтобы отчёт оставался чистым. --nocapture его показывает:
$ cargo test --test checkout -- --nocapturerunning 1 testto pay: 10.80test discounted_breakfast_total ... okТестовые двойники: подделка через трейт
Заголовок раздела «Тестовые двойники: подделка через трейт»Часть кода общается с внешним миром: отправляет письма, вызывает API или смотрит на часы. Ты не хочешь, чтобы тесты отправляли настоящие письма. И проверить, «что будет в 9 вечера», не получится, если код всегда спрашивает настоящие часы.
Идиоматичный ответ в Rust вообще не требует библиотек. Опиши зависимость как трейт, сделай так, чтобы функция принимала всё, что его реализует, и напиши для тестов маленькую подделку (fake). Настоящая программа передаёт настоящую реализацию. Тесты передают подделку.
Здесь подтверждение заказа отправляет письмо покупателю, а о крупных заказах ещё и сообщает отделу продаж:
trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Настоящий отправитель. В реальном приложении он бы вызывал почтовый сервис.struct ConsoleMailer;
impl Mailer for ConsoleMailer { fn send(&mut self, to: &str, body: &str) { println!("письмо для {to}: {body}"); }}
struct Order { id: u32, email: String, total_cents: u32,}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) { let body = format!("Заказ #{} подтверждён", order.id); mailer.send(&order.email, &body); if order.total_cents >= 10_000 { }}
fn main() { let mut mailer = ConsoleMailer; confirm_order(&small, &mut mailer); confirm_order(&big, &mut mailer);}
#[cfg(test)]mod tests { use super::*;
// Подделка: она ничего не отправляет, а просто записывает каждый вызов. #[derive(Default)] struct FakeMailer { sent: Vec<(String, String)>, }
impl Mailer for FakeMailer { fn send(&mut self, to: &str, body: &str) { self.sent.push((to.to_string(), body.to_string())); } }
#[test] fn small_order_sends_one_email() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1); }
#[test] fn big_order_also_tells_sales() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2); }}Вывод:
письмо для [email protected]: Заказ #7 подтверждёнписьмо для [email protected]: Заказ #8 подтверждёнписьмо для [email protected]: Крупный заказ #8В этом примере нет внешних крейтов, поэтому в Playground можно выбрать Test в меню рядом с Run:
running 2 teststest tests::small_order_sends_one_email ... oktest tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sconfirm_order не знает и не интересуется, какой отправитель ей достался. В этом весь фокус. Та же идея работает для трейта Clock (в тестах часы застыли на 9 вечера), для PaymentGateway или для трейта Storage, который в тестах хранит данные в HashMap. Версию с часами ты напишешь сам в практике ниже.
Моки с mockall
Заголовок раздела «Моки с mockall»Писать подделки вручную нормально, когда трейтов один или два. Когда их много, их может сгенерировать крейт mockall (на английском). Добавь его как dev-зависимость, чтобы он использовался только для тестов и примеров (разделы Cargo.toml описаны в уроке Cargo и крейты):
cargo add --dev mockallПоставь #[automock] на трейт, и mockall создаст тип MockMailer. Часть cfg_attr(test, ...) означает «делай это только при тестировании», так что мок никогда не попадёт в настоящую сборку. Подробнее про cfg — в уроке Фичи Cargo и #[cfg].
#[cfg(test)]use mockall::automock;
#[cfg_attr(test, automock)]pub trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Order и confirm_order — такие же, как выше.
#[cfg(test)]mod tests { use super::*;
#[test] fn big_order_also_tells_sales() { let mut mailer = MockMailer::new(); mailer .expect_send() .times(1) .return_const(()); mailer .expect_send() .times(1) .return_const(());
confirm_order(&order, &mut mailer); }}Каждый expect_send() — это ожидание (expectation): «Я ожидаю, что send вызовут ровно один раз с аргументами, которые подходят под withf». return_const(()) говорит, что вернёт мок (здесь — ничего). Когда в конце теста мок удаляется, mockall проверяет, что все ожидания выполнились.
Чтобы увидеть, как он ловит ошибку, поменяй условие в confirm_order на order.total_cents > 12_000. Теперь письмо в отдел продаж не отправляется никогда:
thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1Mockall сообщает, что ожидание было выполнено 0 раз, а ожидалось хотя бы 1.
Подделка или мок? Начни с подделки, написанной вручную. Это обычный Rust, его легко читать, и никаких лишних зависимостей. Бери mockall, когда подделывать нужно много трейтов или когда для тебя действительно важно «вызвано ровно N раз с такими аргументами».
Тестирование свойств с proptest
Заголовок раздела «Тестирование свойств с proptest»Обычный тест проверяет один пример, который ты выбрал: «12.50 разбирается в 1250». Тест свойств (property-based test) формулирует правило, которое должно выполняться для любого ввода, а библиотека пробует сотни случайных вводов, чтобы его нарушить. Это как нанять очень терпеливого человека, который перепробует все странные значения, о которых ты не подумал.
Хорошие свойства часто выглядят так:
- Сделать что-то дважды — значит отменить: если развернуть список дважды, получится исходный.
- Путь туда и обратно возвращает то, с чего начали:
parse(format(x)) == x. - Какой-то факт выполняется всегда: в отсортированном списке столько же элементов, сколько во входном.
Добавь proptest (на английском) командой cargo add --dev proptest и пиши тесты внутри макроса proptest!. Каждая строка name in strategy говорит, какие случайные значения генерировать:
use proptest::prelude::*;use shop::{format_price, parse_price};
proptest! { #[test] fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) { let mut copy = list.clone(); copy.reverse(); copy.reverse(); prop_assert_eq!(copy, list); }
#[test] fn price_survives_format_then_parse(cents in 0u32..10_000_000) { prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents)); }}any::<i32>() означает «любой i32», 0u32..10_000_000 — число из этого диапазона, а vec(..., 0..100) строит списки длиной до 99 элементов. По умолчанию каждое свойство проверяется на 256 случайных случаях. Внутри proptest! используй prop_assert_eq! вместо assert_eq!, чтобы proptest мог аккуратно обработать провал. Оба теста проходят:
running 2 teststest price_survives_format_then_parse ... oktest reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01sЗаметь: это та же проверка, что и медленный тест с #[ignore] выше. Тот тест перебрал все десять миллионов цен за 3 секунды. Тест свойств пробует 256 случайных за сотую долю секунды, поэтому его можно запускать каждый раз.
А теперь ошибёмся нарочно. Кто-то «улучшает» format_price, чтобы она выводила разделитель тысяч, например 1,000.00. Выглядит лучше, но про parse_price забыли:
pub fn format_price(cents: u32) -> String { let euros = cents / 100; let euros = if euros >= 1000 { format!("{},{:03}", euros / 1000, euros % 1000) } else { euros.to_string() }; format!("{euros}.{:02}", cents % 100)}Вот настоящий вывод cargo test --test properties, немного сокращённый:
---- price_survives_format_then_parse stdout ----proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:Test failed: assertion failed: `(left == right)` left: `Err("bad euros in \"1,000.00\"")`, right: `Ok(100000)` at tests/properties.rs:15.minimal failing input: cents = 100000Посмотри на minimal failing input: cents = 100000 («минимальный ввод, на котором тест падает»). Первое случайное значение, на котором тест упал, было, скорее всего, чем-то запутанным вроде 7 384 912. Затем proptest его сжимает (shrinking): пробует всё меньшие значения, на которых тест по-прежнему падает, и сообщает самое простое. 100000 центов — это ровно 1,000.00, первая цена, в которой появляется запятая. Это указывает прямо на ошибку.
Ещё proptest сохраняет провальный случай в файл .proptest-regressions рядом с тестом и при каждом следующем запуске пробует его первым. Добавь этот файл в коммит, чтобы ошибка не могла тихо вернуться. Если тебе больше нравятся библиотеки поменьше, quickcheck (на английском) делает то же самое, но с меньшим набором возможностей.
Снапшот-тесты с insta
Заголовок раздела «Снапшот-тесты с insta»Бывает длинный вывод: чек, отчёт об ошибках, большой JSON-документ. Писать assert_eq! со всем ожидаемым текстом утомительно. Снапшот-тест (snapshot test) в первый раз сохраняет вывод в файл, а при следующих запусках сравнивает новый вывод с этой сохранённой «фотографией».
С insta (на английском) (cargo add --dev insta) тест занимает одну строку:
pub fn receipt(items: &[Item]) -> String { let mut out = String::new(); for i in items { let line = format_price(i.price_cents * i.qty); out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line)); } out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items)))); out}
#[test]fn receipt_layout() { insta::assert_snapshot!(receipt(&sample_cart()));}Первый запуск нарочно падает и записывает файл .snap.new, чтобы ты его проверил. После того как ты его примешь (через cargo insta review из инструмента cargo-insta или просто переименовав файл в .snap), он хранится в src/snapshots/:
---source: src/lib.rsassertion_line: 84expression: receipt(&sample_cart())---coffee x2 9.00bagel x1 3.00TOTAL 12.00Если позже кто-то заменит "TOTAL" на "Total", тест упадёт с понятным diff:
-old snapshot+new results────────────┬─────────────────────────────────────────────────────────────────── 1 1 │ coffee x2 9.00 2 2 │ bagel x1 3.00 3 │-TOTAL 12.00 3 │+Total 12.00────────────┴───────────────────────────────────────────────────────────────────To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`Строка с минусом — старый снапшот, строка с плюсом — новый результат. Если изменение было задумано, принимаешь новый снапшот. Если нет — ты только что нашёл ошибку. Снапшоты отлично подходят для проверок вида «это не должно измениться случайно», но они не говорят, что вывод правильный. Внимательно прочитай первый снапшот, прежде чем его принять.
Бенчмарки с criterion
Заголовок раздела «Бенчмарки с criterion»Если хочешь знать, быстрый ли код, — измерь. Догадки ошибаются на удивление часто. Засекать время внутри #[test] тоже плохая идея: тесты собираются без оптимизаций, а один запуск слишком «шумный».
criterion (на английском) — обычный инструмент для стабильного Rust. Он запускает твой код много раз, сам считает статистику и запоминает прошлый результат, чтобы сказать, стало быстрее или медленнее. Добавь его командой cargo add --dev criterion, затем сообщи Cargo о файле бенчмарка и отключи встроенную обвязку (harness):
[[bench]]name = "stock"harness = falseЭтот бенчмарк отвечает на вопрос: чтобы проверить, есть ли товар на складе, что быстрее для 1000 кодов — Vec или HashSet?
use std::collections::HashSet;use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) { // 1000 кодов товаров: "SKU-0", "SKU-1", ... let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect(); let code_set: HashSet<String> = codes.iter().cloned().collect(); let wanted = String::from("SKU-999"); // худший случай для Vec: последний элемент
c.bench_function("vec contains", |b| { b.iter(|| codes.contains(black_box(&wanted))) }); c.bench_function("hashset contains", |b| { b.iter(|| code_set.contains(black_box(&wanted))) });}
criterion_group!(benches, stock_lookup);criterion_main!(benches);b.iter(...) — это код, который измеряется. std::hint::black_box прячет значение от оптимизатора. Без него компилятор может заметить, что ответ никогда не меняется, вычислить его один раз — и ты будешь измерять пустоту.
Запусти бенчмарк командой cargo bench: она всегда собирает в оптимизированном (release) режиме. На твоём компьютере числа будут другими:
vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) low mild 1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]Found 8 outliers among 100 measurements (8.00%) 4 (4.00%) high mild 4 (4.00%) high severeКак это читать: три времени — это диапазон. Среднее — лучшая оценка, а крайние говорят: «настоящее значение почти наверняка между ними». Здесь HashSet тратит около 19 наносекунд, а Vec — около 2,4 микросекунды, так что в этом случае множество быстрее более чем в 100 раз. «Выбросы» (outliers) — несколько необычно медленных или быстрых запусков, часто из-за других программ на компьютере. Немного выбросов — это нормально.
Запусти его снова после изменения, и criterion сравнит результат с прошлым запуском:
vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs] change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05) No change in performance detected.Изменение меньше 1% и может быть просто шумом, поэтому criterion пишет «No change» («изменений нет»). Ещё он создаёт HTML-отчёт в target/criterion/. Когда стоит (а когда не стоит) думать о скорости, читай в уроке Производительность.
В старом коде тебе могут встретиться функции #[bench]. Они используют встроенный test::Bencher, который работает только на ночном (nightly) компиляторе:
#![feature(test)] // только nightlyextern crate test;
#[bench]fn bench_total(b: &mut test::Bencher) { b.iter(|| (1..=1000u64).sum::<u64>());}На стабильном Rust используй criterion.
Покрытие и CI
Заголовок раздела «Покрытие и CI»Когда тесты уже есть, пригодятся ещё два инструмента. Они ставятся через cargo install, как и другие инструменты из урока Инструменты и установка.
cargo-nextest — более быстрый запускатель тестов. Он запускает каждый тест в отдельном процессе, выводит более понятный отчёт и умеет перезапускать нестабильные тесты. Важно знать: doc-тесты он не запускает, поэтому выполняй ещё и cargo test --doc.
cargo install cargo-nextest --lockedcargo nextest runПокрытие (coverage) показывает, какие строки твои тесты действительно выполнили. cargo-llvm-cov использует поддержку покрытия, встроенную в компилятор, и работает в Linux, macOS и Windows:
cargo install cargo-llvm-covcargo llvm-cov # сводная таблица в терминалеcargo llvm-cov --html # отчёт для браузера в target/llvm-cov/htmlcargo-tarpaulin — более старая популярная альтернатива. Лучше всего работает в Linux, его часто используют в CI:
cargo install cargo-tarpaulincargo tarpaulin --out HtmlОтносись к покрытию как к карте, а не как к оценке. Оно показывает код, которого не касается ни один тест. Но оно не скажет, проверяют ли твои утверждения то, что нужно. 80% покрытия с хорошими проверками лучше, чем 100% со слабыми.
В CI (GitHub Actions, GitLab и так далее) для начала хорошо подойдут те же три команды, которые ты запускаешь у себя. Тогда упавшая проверка везде означает одно и то же:
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo testЧастые ошибки
Заголовок раздела «Частые ошибки»Интеграционные тесты для крейта, где есть только бинарник. Если в проекте есть только src/main.rs, файлы в tests/ ничего не смогут из него импортировать:
error[E0432]: unresolved import `todo` --> tests/tasks.rs:1:5 |1 | use todo::add_task; | ^^^^ use of unresolved module or unlinked crate `todo`Компилятор не нашёл крейт todo, потому что библиотеки с таким именем нет. Исправление: перенеси логику в src/lib.rs, а main.rs оставь маленьким. Тогда библиотеку вызывает main.rs, и твои тесты тоже. Заодно код станет проще использовать повторно.
Общие помощники в tests/common.rs. Каждый файл .rs прямо внутри tests/ считается отдельным тестовым файлом, поэтому в выводе появляется бесполезный лишний раздел:
Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 testsИсправление: используй tests/common/mod.rs и mod common; в каждом тестовом файле, которому он нужен.
Тестовые крейты в [dependencies]. proptest, mockall, insta и criterion должны быть в [dev-dependencies] (используй cargo add --dev). Иначе каждый пользователь твоего крейта будет скачивать и компилировать их.
Тесты с общим состоянием. Тесты выполняются параллельно. Если два теста пишут в один и тот же файл или меняют одну и ту же переменную окружения, они будут падать случайным образом. Дай каждому тесту свою временную папку (поможет крейт tempfile) или, в крайнем случае, запускай с --test-threads=1.
Бенчмарк без black_box или внутри теста. Оптимизатор может выбросить работу, результат которой не используется, а замер времени в #[test] в отладочном режиме мало что говорит. Используй criterion с black_box.
Практика
Заголовок раздела «Практика»1. Приветствие, которое можно протестировать
Заголовок раздела «1. Приветствие, которое можно протестировать»Эту функцию нельзя надёжно протестировать, потому что она всегда спрашивает настоящие часы:
fn greeting() -> &'static str { let hour = current_hour(); // читает системное время match hour { 5..=11 => "Доброе утро", 12..=17 => "Добрый день", _ => "Добрый вечер", }}Перепиши её так, чтобы она принимала трейт Clock с одним методом fn hour(&self) -> u64. Напиши FixedClock, который всегда возвращает один и тот же час, и проверь границы: 11 и 12, 17 и 18. Подсказка: хватит struct FixedClock(u64);. Настоящее приложение передавало бы SystemClock, который читает время.
Показать решение
trait Clock { fn hour(&self) -> u64; // от 0 до 23}
// Часы, которые всегда показывают один и тот же час. Идеально для тестов.struct FixedClock(u64);
impl Clock for FixedClock { fn hour(&self) -> u64 { self.0 }}
fn greeting(clock: &impl Clock) -> &'static str { match clock.hour() { 5..=11 => "Доброе утро", 12..=17 => "Добрый день", _ => "Добрый вечер", }}
fn main() { for hour in [8, 12, 21] { println!("{hour}:00 -> {}", greeting(&FixedClock(hour))); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn morning_ends_at_noon() { assert_eq!(greeting(&FixedClock(11)), "Доброе утро"); assert_eq!(greeting(&FixedClock(12)), "Добрый день"); }
#[test] fn evening_starts_at_six() { assert_eq!(greeting(&FixedClock(17)), "Добрый день"); assert_eq!(greeting(&FixedClock(18)), "Добрый вечер"); }
#[test] fn very_early_is_still_evening() { assert_eq!(greeting(&FixedClock(3)), "Добрый вечер"); }}Вывод:
8:00 -> Доброе утро12:00 -> Добрый день21:00 -> Добрый вечерgreeting больше не решает, откуда берётся время: это решает тот, кто её вызывает. Одно это изменение позволяет проверить любой час за миллисекунды. Выбери Test в меню Playground — все 3 теста пройдут.
2. Найди правило и дай proptest его проверить
Заголовок раздела «2. Найди правило и дай proptest его проверить»Вот функция, которая превращает тег блога вроде " Web Dev " в "web-dev":
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}Напиши два теста свойств с proptest: нормализация уже нормализованного тега ничего не меняет, и в результате никогда нет пробельных символов. Подсказка: стратегия ".*" генерирует случайные строки (это регулярное выражение). Для «нет пробелов» подойдёт prop_assert!(!s.contains(char::is_whitespace)).
Показать решение
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}
fn main() { for tag in ["Rust", " Web Dev ", "GAME dev"] { println!("{tag:?} -> {:?}", normalize_tag(tag)); }}
#[cfg(test)]mod tests { use super::*; use proptest::prelude::*;
proptest! { #[test] fn normalizing_twice_changes_nothing(tag in ".*") { let once = normalize_tag(&tag); prop_assert_eq!(normalize_tag(&once), once); }
#[test] fn result_has_no_whitespace(tag in ".*") { let normalized = normalize_tag(&tag); prop_assert!(!normalized.contains(char::is_whitespace)); } }}Вывод:
"Rust" -> "rust"" Web Dev " -> "web-dev""GAME dev" -> "game-dev"Run в Playground работает, а Test — нет, потому что в Playground нет proptest. В своём проекте добавь его командой cargo add --dev proptest и запусти cargo test:
running 2 teststest tests::result_has_no_whitespace ... oktest tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02sСреди случайных строк есть табуляции, переводы строк и буквы разных алфавитов — ты вряд ли стал бы вводить такое в тест, написанный вручную. «Сделать дважды — то же, что сделать один раз» — свойство, которое стоит проверять для любой функции очистки данных.
- Модульные тесты живут в
#[cfg(test)] mod testsрядом с кодом, интеграционные — вtests/(общие помощники — вtests/common/mod.rs), а doc-тесты — в комментариях///. - Медленные тесты помечай
#[ignore]и запускай черезcargo test -- --ignored. Фильтруй по имени черезcargo test some_name, а выводprintln!смотри с-- --nocapture. - Чтобы протестировать код, который общается с внешним миром, спрячь эту зависимость за трейтом и передавай в тестах подделку. Когда подделки вручную надоедают, используй mockall.
- Тесты свойств (proptest) проверяют правило на сотнях случайных вводов и сжимают провальный случай до самого простого примера. Снапшот-тесты (insta) ловят случайные изменения в большом выводе.
- Скорость измеряй с criterion и
cargo bench, а входные данные оборачивай вstd::hint::black_box.#[bench]требует nightly. - cargo-nextest запускает тесты быстрее, а cargo-llvm-cov и cargo-tarpaulin показывают, какие строки покрыты тестами.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Нескольким файлам в tests/ нужна одна и та же функция-помощник. Куда её положить?
2. Что делает cargo test -- --ignored?
3. Свойство в proptest не выполнилось. Что proptest сообщит как минимальный провальный ввод?
4. Зачем бенчмарки criterion оборачивают значения в std::hint::black_box?
Результат: 0 / 4
Что почитать
Заголовок раздела «Что почитать»- Написание автоматических тестов — книга «Язык программирования Rust» (перевод на русский): официальная глава о том, как писать, запускать и организовывать модульные и интеграционные тесты.
- Тестирование — Rust на примерах (перевод на русский): короткие примеры модульных тестов, doc-тестов, интеграционных тестов и dev-зависимостей.
- The proptest book (на английском): стратегии, сжатие, настройка и чем proptest отличается от quickcheck.
- Документация mockall (на английском):
#[automock], ожидания, проверка аргументов, возвращаемые значения и моки для структур. - Criterion.rs user guide (на английском): как начать, как читать вывод, как сравнивать функции и HTML-отчёты.