Лабораторна робота № 4. Перший проєкт на Cargo з юніт-тестом
Мета
Навчитися створювати, збирати й тестувати проєкт на Rust за допомогою інструменту Cargo: ініціалізувати структуру проєкту, підключати зовнішню залежність (crate) і писати юніт-тести, зокрема з підміною (моком) вхідних даних через трейти.
Теоретичні відомості
Cargo — офіційний менеджер збірки й пакетів Rust, що постачається разом із rustc через
rustup. Він виконує чотири базові операції: cargo new створює структуру нового проєкту;
cargo build компілює проєкт (без прапорця — у debug-режимі з символами налагодження й без
оптимізацій; з --release — з повними оптимізаціями); cargo run компілює й одразу запускає
бінарник; cargo test компілює й запускає всі функції, позначені атрибутом #[test].
Стандартна структура бінарного проєкту:
lockpick/
├── Cargo.toml
├── Cargo.lock
└── src/
└── main.rs
Cargo.toml — маніфест: секція [package] задає назву, версію (семантичне версіонування
major.minor.patch) і edition (набір мовних правил компілятора; станом на 2026 рік актуальні
edition = "2021" і новіша edition = "2024" — новий проєкт, створений cargo new, отримує
найновішу стабільну edition за замовчуванням). Секція [dependencies] перелічує зовнішні
бібліотеки — crate — які завантажуються з реєстру crates.io. Cargo.lock фіксує точно
резолвнуті версії всього дерева залежностей, гарантуючи відтворювану збірку на будь-якій машині
з тим самим Cargo.lock.
Юніт-тест — функція, позначена атрибутом #[test], зазвичай розміщена в підмодулі
#[cfg(test)] mod tests { use super::*; ... }. Атрибут #[cfg(test)] — умова компіляції: цей
код збирається лише коли Cargo викликано з ціллю test, тож тестовий код ніколи не потрапляє у
фінальний бінарник, зібраний командою cargo build/cargo run. Тест провалюється, якщо тіло
функції панікує — найчастіше через макроси assert!(умова), assert_eq!(ліворуч, праворуч),
assert_ne!(ліворуч, праворуч), кожен з яких (крім assert!) при провалі друкує обидва значення,
що суттєво спрощує діагностику порівняно з голим assert!(a == b).
Тест може також мати сигнатуру fn some_test() -> Result<(), E> замість (); тоді всередині
тесту працює оператор ?, а провал позначається поверненим Err замість паніки — це особливо
зручно для тестів, що самі виконують операції, здатні провалитись (парсинг, введення-виведення).
Мок (mock) — підставна реалізація залежності, яка повертає заздалегідь визначений результат
замість звернення до реального (мережевого, файлового, часового) джерела, роблячи тест швидким і
детермінованим. У Rust немає окремого фреймворка, обов'язкового для мокування: базовий підхід —
визначити trait, яким користується логіка, а в тестовому модулі реалізувати цей трейт
структурою з фіксованою поведінкою:
trait DataSource {
fn fetch(&self) -> String;
}
fn process(source: &dyn DataSource) -> String {
source.fetch().to_uppercase()
}
Основна функція process працює через &dyn DataSource (об'єкт трейту, динамічна диспетчеризація)
і не знає, чи це реальне джерело, чи тестовий підмінник — це і є суть інверсії залежностей у
Rust без окремих бібліотек для мокування.
Обладнання та програмне забезпечення
- Codespaces / devcontainer курсу з попередньо встановленим Rust toolchain (
rustc,cargo) - Редактор з розширенням rust-analyzer (входить у типовий devcontainer курсу)
- Термінал усередині контейнера
Порядок виконання
- Відкрити Codespaces/devcontainer лабораторної. Переконатися, що
cargo --versionвиводить версію інструменту без помилок. - Виконати
cargo new lockpick --bin(або скористатися наданим стартовим кодом — див.starter/README.md). Очікуваний результат: створено каталог зCargo.tomlіsrc/main.rs. - Реалізувати функцію
fn generate_key(seed: &str) -> Stringіmain(), що її викликає. Зібрати командоюcargo build— очікуваний результат: збірка без помилок компіляції. - Запустити
cargo runі переконатися, що програма виводить непорожній рядок у stdout. - Написати перший юніт-тест у модулі
#[cfg(test)] mod tests { ... }і запуститиcargo test— очікуваний результат:test result: ok. N passed; 0 failed. - (Рівень 2) Додати залежність у
Cargo.toml(наприклад,sha2 = "0.10") і фактично використати її всерединіgenerate_key, а не лише оголосити в маніфесті. - (Рівень 3) Ввести
trait DataSourceдля джерела вхідних даних, написати мок-реалізацію цього трейту в тестовому модулі й тест, що використовує мок замість реального джерела, покриваючи і успішний, і помилковий сценарій.
Завдання за рівнями
Рівень 1 (оцінка 3)
Ініціалізувати проєкт на Cargo, реалізувати структуру (generate_key і main), зібрати
(cargo build), запустити (cargo run) і написати щонайменше один змістовний юніт-тест, що
перевіряє реальну поведінку функції (наприклад, детермінованість результату), запущений через
cargo test. Сигнатура fn generate_key(seed: &str) -> String має лишитись незмінною.
Рівень 2 (оцінка 4, захист)
Підключити сторонній crate для генерації хешів або шифрування (наприклад, sha2) у секцію
[dependencies] файлу Cargo.toml і фактично використати його (use <crate>::...) усередині
generate_key. На захисті — усно пояснити призначення обраної залежності та принцип роботи
використаного алгоритму на високому рівні (наприклад, чому SHA-256 детермінований і
односторонній).
Рівень 3 (оцінка 5, розширення)
Написати комплексний тест із моком (підміною) вхідних даних від "корпоративного сервера":
визначити trait DataSource із методом на кшталт fn fetch(&self) -> String, реалізувати його
для реального (чи заглушкового) джерела і окремо — мок-структурою в тестовому модулі, що
повертає фіксоване значення без будь-якого реального звернення до мережі чи диска. Тест має
покривати і успішний сценарій (мок повертає коректні дані), і хоча б один граничний/помилковий
(наприклад, порожній рядок від джерела).
Розібраний приклад
Розберемо, чим змістовний тест відрізняється від формального. Реалізація:
fn generate_key(seed: &str) -> String {
let mut acc: u32 = 0;
for (i, b) in seed.bytes().enumerate() {
acc = acc.wrapping_add((b as u32).wrapping_mul(i as u32 + 1));
}
format!("{:08X}", acc)
}
Три тести, що реально перевіряють різні властивості:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_key_is_deterministic() {
assert_eq!(generate_key("alpha"), generate_key("alpha"));
}
#[test]
fn generate_key_differs_for_different_seed() {
assert_ne!(generate_key("alpha"), generate_key("beta"));
}
#[test]
fn generate_key_handles_empty_seed_without_panicking() {
assert_eq!(generate_key(""), "00000000");
}
}
Кожен тест ловить окремий клас регресій: перший — випадкову недетермінованість (наприклад,
випадкове звернення до SystemTime::now()); другий — ігнорування вхідного параметра; третій —
паніку на крайовому випадку порожнього вводу. Формальний assert!(true) не ловить жодної з цих
регресій і саме тому відхиляється автогрейдером.
Корисні команди під час розробки:
cargo test generate_key_is_deterministic # запустити один конкретний тест
cargo test -- --show-output # показати println! навіть при успіху
cargo build --release # оптимізована збірка без символів налагодження
Контрольні питання
- Яка різниця між
cargo buildіcargo run? - Що робить атрибут
#[cfg(test)]і чому тестовий код не потрапляє у фінальний бінарник? - Чим
cargo build --releaseвідрізняється від звичайногоcargo build? - Що таке семантичне версіонування і як Cargo використовує його при виборі версії залежності?
- Навіщо потрібен мок, якщо можна протестувати код напряму проти реального сервера?
- Який макрос використати, щоб тест провалився з повідомленням, якщо два значення не рівні?
- Де Cargo зберігає завантажені залежності і що робить файл
Cargo.lock? - Чим trait відрізняється від конкретного типу і чому це дозволяє підміняти реалізацію в тесті?
Парні теми СРС
- Рівень 2: srs07 — Модель володіння Rust проти збирача сміття і ручного керування пам'яттю
- Рівень 3: srs08 — Екосистема crates.io і семантичне версіонування залежностей
Критерії оцінювання та форма звіту
Студент здає лабораторну тегом submit/lab04 у своєму репозиторії/форку. Автоматичні тести
рівня 1 (autograder/) перевіряють: проєкт збирається без помилок (cargo build), проєкт
запускається (cargo run) і виводить очікуваний результат, cargo test завершується успішно й
містить принаймні один тест, що реально перевіряє логіку (не порожній assert!(true)), а
сигнатура fn generate_key(seed: &str) -> String лишилась незмінною.
Для рівня 2 автотести додатково перевіряють наявність зовнішньої залежності в Cargo.toml і її
фактичне використання в коді (а не просто оголошення). Захист рівня 2 — усне пояснення, навіщо
потрібна саме ця залежність і як працює обраний алгоритм хешування/шифрування на високому рівні.
Рівень 3 оцінюється вручну викладачем: наявність трейта для джерела даних, мок-реалізації в тестовому модулі й тесту, що перевіряє поведінку логіки на підставних даних без звернення до реального джерела. Оцінюється якість покриття (чи мок покриває і успішний, і помилковий сценарій).
Форма звіту: короткий опис реалізації (3–5 речень), скріншот терміналу із зеленим виводом
cargo test, для рівня 2 — фрагмент Cargo.toml із підключеною залежністю, для рівня 3 —
фрагмент коду мок-реалізації з коментарем, який сценарій вона перевіряє.