Съдържание на курса
Урок 6 — Модули, тестове и Cargo
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: 2 × 45 мин.
Какво изграждаш: проектът revisor, подреден и с тестове.
Какво научаваш: модули и видимост, unit тестове и интеграционни тестове, cargo test, зависимости и версии.
The Rust Book, глави 7, 11 и 14. Rustlings: modules, tests.
След края ще можеш да
- Разделиш една програма на Rust на модули с ясни отговорности и да навигираш пътищата им с
crate,selfиsuper. - Обясниш защо всичко е частно по подразбиране и да избираш между
pub,pub(crate)и частен API. - Разграничаваш unit тест от интеграционен тест и да знаеш какъв клас проблем открива всеки.
- Пишеш тестове с
#[test],assert!,assert_eq!,matches!и#[should_panic]. - Изпълняваш, филтрираш и диагностицираш тестове с
cargo test. - Четеш
Cargo.tomlиCargo.lock, да добавяш зависимост с разумна версия и да преглеждаш транзитивното ѝ дърво. - Поддържаш
revisorпроверяем сcargo fmt --check,cargo clippy --all-targets -- -D warningsиcargo test.
Защо, преди как
Досега revisor можеше да се побере в малко файлове, защото курсът представяше частите на езика една по една. Вече познаваш типовете, които моделират една услуга, колекциите, които съхраняват списъка, грешките, които описват външни повреди, и trait-овете, които изразяват договори. Следващият проблем не е да напишеш още една функция: той е да не позволиш тези функции да се превърнат в една-единствена маса, трудна за четене, тестване и промяна.
Един огромен файл не спира автоматично да работи. Проблемът се появява, когато една на вид локална промяна те принуждава да разбираш твърде много неща едновременно. Ако кодът, който чете YAML, валидира услуги, прави HTTP заявки, произвежда JSON и парсва аргументи, живее разбъркан, един тест за формат може да завърши с изискване за мрежа; промяна в конфигурацията може да засегне бинарния файл; а една частна функция може да завърши използвана отвсякъде само защото никой не е дефинирал граница.
Модулите са тези граници. Те не са папки, за да „изглежда подреден“ проектът; те са имена за отговорности и, в Rust, също изрична част от контрола на достъпа. Един модул може да каже: „тук се дефинира какво е една услуга“, „тук се зарежда конфигурацията“ или „тук едно състояние се превръща в таблица“. Който използва един модул, познава публичния му интерфейс; не се нуждае и не бива да зависи от вътрешните подробности, с които е имплементиран.
Важната дума е интерфейс. В Go една папка дефинира пакет, а главна начална буква решава дали едно име може да премине границата на пакета. Rust е по-подробен. Една папка може да помага за организирането на файловете, но видимостта зависи от модулите и от pub. Име без pub е частно, дори да е в друг файл на същия проект. Това изглежда строго в началото, но не позволява една помощна функция да се превърне случайно в обещание към останалата част на програмата.
revisor прилага тази идея с два продукта в един и същ Cargo пакет. src/lib.rs декларира библиотека: там живее преизползваемата и проверима логика. src/main.rs декларира бинарния файл: получава аргументи, извиква библиотеката, отпечатва резултата и решава изходния код. Разделянето на двете позволява да се тества логиката, без да се извиква командният ред във всеки случай. Позволява също интеграционните тестове да използват revisor, както би го използвало друго приложение: импортирайки изключително публичния му API.
Това е същият принцип, който използва в Go, когато раздели пакети по отговорност, но Rust прави договора по-видим. В Go функция с малка буква не може да се импортира от друг пакет; в Rust функция, struct, поле или модул изисква декларирана видимост. И двете решения търсят да ограничат зависимостите. Rust ти дава повече нива, за да изразиш това намерение: публичен за всеки, който импортира библиотеката, публичен само в текущия пакет или публичен за родителски модул.
Тестовете превръщат тези граници в нещо проверимо. Един unit тест живее близо до функцията, която тества, и може да изследва частни подробности. Полезен е за малки правила: ако YAML не декларира timeout_ms, прилага ли се стойността по подразбиране? отхвърля ли се празен списък? запазва ли таблицата подравняването си? Един интеграционен тест живее под tests/, компилира се като друг crate и може да използва само pub. Полезен е, за да провериш, че интерфейсът наистина стига: ако някой построи Servicio, извика revisar и получи Estado, работи ли публичният договор, без да зависи от вътрешни подробности?
Не бъркай много тестове с добро покритие на решенията. Една колекция от тестове може да има сто теста, които повтарят един и същ здрав случай, и нито един, който да покрива невалиден URL, липсващ файл или услуга, която се бави твърде много. Не превръщай и процента покритие в изолирана цел. Полезният въпрос е: „кое важно поведение би могло да се счупи, без да се зачерви някой тест?“ revisor тества здрави състояния, HTTP 500, времена на изчакване, невалидна конфигурация, JSON формат и изходни кодове, защото това са поведения, които имат значение за този, който използва програмата.
cargo събира тези решения. Не само компилира: знае кои файлове образуват пакета, какви зависимости му трябват, какво издание на Rust използва, кои тестове съществуват и какви артефакти трябва да построи. В фигурите на курса продължаваш да извикваш rustc директно, за да видиш изолиран пример. В реалния проект използваш cargo, защото вече не съществува разумно ръчно извикване, което да помни всички модули, crate-ове, характеристики и цели за тестване.
Дисциплината на този урок е проста: организирай по отговорност, отваряй най-малката необходима публична повърхност и тествай всяка граница от правилната страна. Ако един unit тест се нуждае от мрежа, вероятно си смесил чисто правило с инфраструктура. Ако един интеграционен тест трябва да импортира частна подробност, вероятно публичният ти API не изразява това, от което се нуждае друг потребител. Ако cargo test казва, че не е намерил тестове, още не си честитвай: провери броя.
Понятията
Модули: имена, пътища и отговорности
Един модул групира свързани имена. Може да се декларира във файл с mod nombre { ... } или да живее в друг файл. В съвременен Cargo пакет src/lib.rs и src/main.rs са различни корени на crate. От всеки от тях crate означава „коренът на този crate“; self означава текущия модул; а super означава родителския модул.
Честа грешка е да се мисли, че файл и модул са синоними. Един файл може да съдържа няколко модула, а един модул може да се отвори в друг файл. Структурата на файловете помага на човек да намира код; структурата на модулите определя как Rust разрешава пътища и прилага видимост. Не проектирай първо празно дърво от папки. Започни от отговорности, които имат стабилна причина да се променят поотделно.
Фиг. 6.1 | Модул предлага публична функция и пази частната си подробност.
// fig06_01.rs
mod reporte {
fn etiqueta(sano: bool) -> &'static str {
if sano {
"OK"
} else {
"FALLA"
}
}
pub fn linea(nombre: &str, sano: bool) -> String {
format!("{nombre}: {}", etiqueta(sano))
}
}
fn main() {
println!("{}", reporte::linea("catalogo", true));
println!("{}", reporte::linea("pagos", false));
}
$ rustc --edition 2024 fig06_01.rs && ./fig06_01
catalogo: OK
pagos: FALLA
reporte::linea е достъпна от main, защото има pub. Функцията etiqueta няма pub, така че може да се използва само вътре в reporte. Това решение не скрива информация по загадъчност: изразява, че другите части на програмата имат нужда от завършен ред, а не от вътрешното правило, което превежда булева стойност в текст. Ако по-късно смениш "FALLA" с "NO DISPONIBLE", само модулът собственик трябва да се промени.
В revisor коренът на библиотеката изброява публичните ѝ отговорности. Няма модул, наречен utilidades, защото това име не обяснява каква отговорност притежава. config зарежда и валидира конфигурацията; modelo дефинира речника; reporte превежда състояния в текст или JSON; revisar пита услугите.
//! El `revisor` del curso de Rust: recibe una lista de servicios, los consulta
//! todos a la vez y produce un reporte.
//!
//! La lógica vive aquí, en la biblioteca, y `main.rs` solo lee los argumentos y
//! llama (lección 6): así todo lo de abajo se puede probar desde fuera.
//!
//! - [`modelo`]: el vocabulario (`Servicio`, `Estado`, `EstadoJson`).
//! - [`config`]: lee y valida el archivo YAML de servicios.
//! - [`revisar`]: consulta un servicio por HTTP, o todos a la vez con un límite.
//! - [`reporte`]: convierte los estados en tabla o en JSON.
pub mod config;
pub mod modelo;
pub mod reporte;
pub mod revisar;
Думата pub пред всеки mod прави тези модули входа на библиотеката. Това не прави публично цялото им съдържание. Всеки модул от своя страна решава кои struct-ове, функции и константи излага. Тази композиция е предимство: публикуването на reporte позволява да се извиква revisor::reporte::tabla, но не задължава да се публикуват помощните функции, които подреждат редове или изчисляват етикети.
Дървото от модули на revisor не претендира да е универсална йерархия. В малък проект четири плоски модула са по-четими от дълга верига от папки. Когато една отговорност нарасне достатъчно, може да се раздели на подмодули. Въпросът не е „колко файла трябва да има един професионален проект?“, а „мога ли да опиша с едно изречение какво принадлежи тук и какво не?“.
Видимост: частното по подразбиране като дизайн
Rust започва затворен. Един item без pub е видим в своя модул и в потомците му, но не за съседните модули нито за родителя. Това правило е по-ограничително, отколкото много програмисти очакват след JavaScript, Python или Go, където една функция на файл обикновено е достъпна в пакета. Намерението е да те задължи да проектираш интерфейса, преди да зависиш от подробност.
pub отваря един item към всеки, който може да стигне до модула, който го съдържа. pub(crate) отваря item-а за целия текущ crate, но не за някой, който импортира библиотеката от друг пакет. pub(super) отваря item-а единствено за родителския модул. Съществува и pub(in ruta), полезно, когато точна граница от модули изразява реално правило, макар да е по-рядко в малки проекти.
Не означавай всичко с pub, за да заглушиш грешки на видимост. Това има цена: всеки потребител може да започне да зависи от тези имена и след това промяната на една вътрешна функция се превръща в разрив на API. За частен бинарен файл тази цена остава в хранилището; за публикувана библиотека може да те принуди да запазиш случайно решение с години. Започни частно и отвори само това, от което друга част има нужда.
Компилаторът различава име, което не съществува, от име, което съществува, но е затворено. В този случай функцията съществува, но main се опитва да премине частна граница.
Фиг. 6.2 | Достъпът до частна функция произвежда E0603.
// fig06_02.rs
mod config {
fn ruta_por_omision() -> &'static str {
"servicios.yaml"
}
}
fn main() {
println!("{}", config::ruta_por_omision());
}
$ rustc --edition 2024 fig06_02.rs
error[E0603]: function `ruta_por_omision` is private
--> fig06_02.rs:9:28
|
9 | println!("{}", config::ruta_por_omision());
| ^^^^^^^^^^^^^^^^ private function
|
note: the function `ruta_por_omision` is defined here
--> fig06_02.rs:3:5
|
3 | fn ruta_por_omision() -> &'static str {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0603`.
Механичната поправка би била да се напише pub fn ruta_por_omision. Преди да го направиш, попитай дали пътят по подразбиране трябва да е част от договора на config. Ако друг модул наистина има нужда да го консултира, може да е разумна публична функция. Ако искаш само main да зареди нормалния файл, може би е по-удобно config да предложи публична функция от по-високо ниво и да запази този низ като частна подробност.
Модулът modelo на revisor показва подбран публичен API. Servicio е публичен, защото конфигурацията, интеграционните тестове и други модули трябва да го построяват. Полетата му са публични, защото програмата трябва да чете и променя декларираните данни. Константата за timeout по подразбиране, напротив, остава частна: който използва Servicio::new, получава правилото, без да зависи от начина, по който е съхранено.
/// Cuánto se le espera a un servicio que no declara su propio tiempo límite.
const TIMEOUT_POR_OMISION_MS: u64 = 5000;
/// A partir de cuántos milisegundos una respuesta sana se reporta como lenta.
pub const UMBRAL_LENTO_MS: u64 = 1000;
fn timeout_por_omision() -> u64 {
TIMEOUT_POR_OMISION_MS
}
impl Servicio {
/// Un servicio con el tiempo límite por omisión (5 segundos).
pub fn new(nombre: &str, url: &str) -> Self {
Self {
nombre: nombre.to_string(),
url: url.to_string(),
timeout_ms: TIMEOUT_POR_OMISION_MS,
}
}
}
Забележи разликата между излагане на константа и излагане на функция. UMBRAL_LENTO_MS е правило, от което другите модули наистина имат нужда, за да класифицират отговори. timeout_por_omision съществува само за да може serde да извика стойността по подразбиране вътре в модела. Публикуването му не дава полезна възможност на потребителя и разширява повърхността, която би трябвало да се поддържа.
Unit тестове: малко свойство, близо до кода
Един unit тест проверява единица поведение в собствения си модул. Rust ги пише обикновено вътре в модул tests, означен с #[cfg(test)]. Този атрибут показва, че модулът се компилира единствено при построяването на тестовете. Продукционният бинарен файл не зарежда тези функции, нито помощните им.
use super::* импортира в tests имената на родителския модул. Това позволява да се тестват частни подробности нарочно. Не е уловка срещу видимостта: тестът живее като потомък на същия модул и проверява вътрешната имплементация. Един интеграционен тест ще има друго ограничение, защото представлява външен потребител.
Основните твърдения са assert!, за булево условие; assert_eq!, за сравняване на очаквано и получено; и assert_ne!, за да се твърди, че две стойности не са равни. Всички приемат допълнително съобщение с форматиране. matches! е особено полезен с enum-и: позволява да се провери вариантът и, ако е нужно, условие върху данните, които носи.
Фиг. 6.3 | Unit тестове, твърдение за enum и очаквана паника.
// fig06_03.rs
enum Estado {
Ok { codigo: u16, ms: u64 },
Falla(String),
}
fn resumen(e: &Estado) -> String {
match e {
Estado::Ok { codigo, ms } => format!("OK {codigo} en {ms}ms"),
Estado::Falla(msg) => format!("FALLA: {msg}"),
}
}
fn dividir(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("dividir por cero");
}
a / b
}
fn main() {
println!("{}", resumen(&Estado::Ok { codigo: 200, ms: 100 }));
println!("{}", dividir(10, 2));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn estado_ok_con_200() {
let e = Estado::Ok { codigo: 200, ms: 100 };
assert!(matches!(e, Estado::Ok { .. }));
}
#[test]
fn falla_sin_codigo() {
assert_eq!(resumen(&Estado::Falla("x".into())), "FALLA: x");
}
#[test]
#[should_panic(expected = "dividir por cero")]
fn panico_esperado() {
dividir(1, 0);
}
}
$ rustc --edition 2024 --test fig06_03.rs && ./fig06_03 --test-threads=1
running 3 tests
test tests::estado_ok_con_200 ... ok
test tests::falla_sin_codigo ... ok
test tests::panico_esperado - should panic ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
#[should_panic] не означава, че паниките са нормален начин за обработка на невалидни данни. В revisor един неправилен URL трябва да завърши като Result и съобщение за този, който е извикал програмата, не като паника. Тестът за паника служи, когато един договор умишлено спира изпълнението при нарушен инвариант. Аргументът expected има значение: потвърждава, че паниката е произлязла от очакваната причина, а не от друг случаен провал.
Един тест трябва да описва поведение, не случайна имплементация. Името falla_sin_codigo съобщава правило от домейна: една повреда се показва без HTTP код. Име като prueba_resumen_2 казва само, че някой е написал тест. Когато се провали след месеци, името ще е първата следа, за да разбереш кое решение на програмата се е променило.
В config.rs unit тестовете не правят HTTP заявки и не изпълняват бинарния файл. Строят малки данни и извикват validar, която е единицата, отговорна за проверката на списъка. Това държи колекцията от тестове бърза и кара всеки провал да сочи конкретно правило.
#[test]
fn validar_rechaza_nombre_repetido() {
let v = vec![
Servicio::new("a", concat!("http", "://x")),
Servicio::new("a", concat!("http", "://y")),
];
let err = validar(&v).unwrap_err().to_string();
assert!(err.contains("repetido"), "mensaje: {err}");
}
#[test]
fn validar_rechaza_url_sin_esquema() {
let v = vec![Servicio::new("a", "localhost:80")];
assert!(validar(&v).is_err());
}
#[test]
fn validar_rechaza_timeout_cero() {
let mut s = Servicio::new("a", concat!("http", "://x"));
s.timeout_ms = 0;
assert!(validar(&[s]).is_err());
}
Първият тест инспектира част от съобщението, защото тук текстът е част от преживяването на този, който поправя YAML. Останалите само проверяват, че съществува грешка. Не всички тестове трябва да сравняват пълни низове. Сравнявай точната подробност, когато е публичен договор; за вътрешни грешки проверката на типа, на условие или на съществуването на грешката обикновено произвежда по-малко крехки тестове.
Интеграционни тестове: библиотеката, видяна отвън
Cargo разпознава tests/ като мястото на интеграционните тестове. Всеки Rust файл директно в тази папка се компилира като отделен crate. Затова не може да използва частни функции, нито да импортира вътрешния модул tests на библиотеката, нито да предполага подробности за файловете. Може да използва само това, което библиотеката експортира с pub.
Това ограничение е полезно. Един API може да има отлични unit тестове и пак да е неудобен или недостатъчен за този, който се опитва да го използва отвън. Интеграционните тестове откриват този проблем, защото преминават през същата граница, през която би преминало друго бинарно приложение. Ако трябва да нарушиш капсулирането, за да ги напишеш, първо провери дали не липсва разумна публична операция; не превръщай всичко в pub като автоматична реакция.
revisor има файл tests/integracion.rs. Импортира типовете и функциите, от които се нуждае един публичен потребител: Estado, Servicio, revisar и revisar_todos. Не импортира частни функции, които строят заявки, нито подробности на reqwest.
use revisor::modelo::{Estado, Servicio};
use revisor::revisar::{revisar, revisar_todos};
fn servicio(nombre: &str, direccion: &str, ruta: &str, timeout_ms: u64) -> Servicio {
Servicio {
nombre: nombre.to_string(),
url: ["http:", "//", direccion, ruta].concat(),
timeout_ms,
}
}
Помощната функция servicio принадлежи на теста, не на библиотеката, защото съществува само за да направи четими тестовите случаи. Това е здравословно разграничение: не повишавай една функция до продукция само защото два теста я повтарят. Библиотеката трябва да съдържа възможности на програмата; колекцията от тестове може да съдържа малки инструменти за подготовка на сценарии.
Следващият тест стартира локален HTTP сървър, дефиниран в tests/comun/mod.rs, извиква публичния API и проверява получения вариант. Не зависи от реална услуга в интернет, от акаунт, нито от определен час. Това не позволява мрежова повреда да превърне един детерминиран тест във фалшива аларма.
#[tokio::test]
async fn un_500_es_falla_con_su_codigo() {
let d = comun::servidor_demo();
let cliente = reqwest::Client::new();
let e = revisar(&cliente, &servicio("mal", &d, "/error", 2000)).await;
assert!(
matches!(&e, Estado::Falla { motivo, .. } if motivo == "codigo 500"),
"estado: {e:?}"
);
}
#[tokio::test] се появява, защото функцията revisar е асинхронна. Урок 7 разглежда в дълбочина какво означава да се чака един future и как работи runtime-ът. Тук е важно да разпознаеш границата: интеграционният тест използва същия асинхронен API, който ще използва бинарният файл, но заменя интернет с контролиран локален сървър.
Освен интеграционни тестове проектът има тестове на бинарния файл. Те изпълняват компилирания revisor, подават му временен YAML и проверяват stdout, stderr и изходния код. Са по-бавни и по-широки от един unit тест, така че не заместват другите; проверяват последната граница, където се срещат аргументите, конфигурацията, докладите и изходът на процеса.
cargo test: изграждане, избиране и четене на резултати
cargo test открива unit тестовете, интеграционните, тези на бинарните файлове и тези на документацията; компилира необходимите цели (targets) и изпълнява всеки набор. Той е нещо повече от съкращение на rustc --test: Cargo познава зависимостите и построява всеки crate с правилните пътища.
Командите, които ще използваш най-често, са тези:
cargo test
cargo test validar_rechaza_nombre_repetido
cargo test --test integracion
cargo test -- --nocapture
cargo test --release
Първата команда изпълнява всичко. Втората филтрира по част от името на теста; полезна е, за да работиш по едно-единствено правило, без да чакаш целия набор. Третата избира конкретно интеграционния файл, наречен integracion. -- отделя опциите на Cargo от опциите на изпълнителя на тестове: --nocapture позволява да се видят println! на тест, който минава, нещо полезно за временна диагностика, не като заместител на твърдение. --release компилира с оптимизации; използвай го, когато поведението наистина зависи от профила или когато измерваш производителност, не като ежедневен режим.
Тестовете могат да се изпълняват паралелно. Това е правилно, ако всеки създава собствени данни и не зависи от реда на изпълнение. Ако диагностицираш изход или тест споделя ресурс, който още не можеш да изолираш, използвай:
cargo test -- --test-threads=1
Не превръщай този флаг в навик. Набор от тестове, който работи само последователно, може да крие глобално състояние или временни файлове с имена, които се сблъскват. В revisor тестовите сървъри искат от системата свободен порт и всеки случай използва собствени данни; това позволява тестовете да се изпълняват, без да зависят от определен ред.
Най-простата уловка е, че cargo test може да завърши успешно, без да е изпълнил съответен тест. Лошо написан филтър може да произведе изход с филтрирани тестове; един crate може да няма нито един #[test]; а файл, поставен извън tests/, може да не е интеграция. Винаги чети редовете running N tests и test result. Изходният код нула означава, че изпълнителят не е намерил провал, не че намерението ти е проверено.
Проектът поддържа логиката в библиотеката и стартирането в бинарния файл. Бинарният файл импортира публичния API като всеки друг вътрешен потребител. Това разделяне е причината интеграционните тестове да могат да импортират revisor със същото име.
use std::process::ExitCode;
use revisor::{config, reporte, revisar};
use clap::Parser;
Пътят revisor::{config, reporte, revisar} не използва crate::, защото main.rs е друг crate в същия пакет. От гледна точка на бинарния файл revisor е библиотеката, декларирана от src/lib.rs. Това е малка разлика в синтаксиса с важно следствие за дизайна: бинарният файл няма привилегия да стига до частните подробности на библиотеката.
Зависимости, версии и работата на cargo
Cargo.toml е декларативният манифест на пакета. Казва как се казва, какво издание използва, какви преки зависимости му трябват и какви профили на изграждане съществуват. Cargo.lock регистрира конкретното разрешаване: точните версии на преките и транзитивните зависимости, които Cargo е избрал, когато е построил проекта.
revisor не зависи само от стандартната библиотека. Това е умишлено: YAML, асинхронен HTTP, JSON и пълен команден ред живеят в специализирани crate-ове. Декларираните зависимости са тези, които проектът наистина използва.
[dependencies]
anyhow = "1.0.104"
clap = { version = "4.6.7", features = ["derive"] }
futures = "0.3.34"
reqwest = { version = "0.13.5", features = ["json"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
yaml_serde = "0.10.7"
tokio = { version = "1.53.1", features = ["full"] }
Една бележка за един от тези редове. До 2024 г. най-използваният crate за четене на YAML със serde беше serde_yaml. Авторът му, David Tolnay, спря да го поддържа: последната му версия е 0.9.34+deprecated, от март 2024 г., и crates.io я отбелязва като остаряла. Все още се компилира и работи, но вече не получава поправки нито подобрения, така че не е препоръчително да започваш нов проект с нея. revisor използва yaml_serde, продължение, публикувано от организацията на YAML в GitHub: хранилището му го представя като поддържаната разклонена версия на serde_yaml и обещава същия интерфейс. Затова промяната почти не засяга кода: това, което знаеш за serde_yaml::from_str, важи еднакво за yaml_serde::from_str. Съществуват и други разклонения и алтернативи; преди да избереш някое, виж датата на последната му версия и дали хранилището му все още получава промени. (Данни, консултирани в crates.io на 2 октомври 2026 г.)
Версия като "1.0.104" сама по себе си не фиксира всяка цифра завинаги. В Cargo тази спецификация използва семантична съвместимост с неявен оператор caret: позволява съвместими обновявания в рамките на същата основна версия. Cargo.lock е това, което прави повторяемо конкретното компилиране на бинарния файл. Затова lockfile-ът на revisor трябва да отива в хранилището: човек, който клонира приложението, трябва да разреши същите известни версии, а не нова комбинация, която днес изглежда съвместима.
За публикувана библиотека отговорът е по-малко категоричен. cargo new регистрира Cargo.lock в хранилището по подразбиране, а често задаваните въпроси на Cargo (Cargo FAQ) казват, че дали да го версионираш, зависи от това, от което се нуждае твоят пакет. Версионирането му дава повторяеми компилации: помага да се намери с git bisect коя промяна е въвела грешка, непрекъснатата интеграция да се проваля само заради нови commit-и, а не заради зависимост, променена отвън, и да се проверяват с известни версии неща като минималната версия на Rust или точния текст на съобщенията за грешка. Но този файл не защитава този, който използва твоята библиотека: потребителите разрешават зависимостите с това, което декларира твоят Cargo.toml, и със собствения си Cargo.lock, а cargo install по подразбиране игнорира Cargo.lock на пакета и избира най-новите съвместими версии, освен ако не му подадеш --locked. Накратко: приложение като revisor е добре да го версионираш винаги, защото е крайният продукт, който искаш да възпроизвеждаш; за библиотека реши според това, което искаш да гарантираш, и ако не я версионираш, изпробвай от време на време с най-новите зависимости.
Добавяй зависимост с Cargo, вместо да пишеш на ръка ред, който не разбираш:
cargo add serde --features derive
cargo add tokio --features full
cargo tree
cargo update
cargo add обновява манифеста и разрешава lockfile-а. Характеристиките, или features, активират незадължителни части на един crate. serde се нуждае от derive, за да съществува #[derive(Serialize, Deserialize)]; tokio се нуждае от възможности за runtime, мрежа и макроси за сегашната програма. Не включвай full по рефлекс в нов проект, ако ти трябва само малка част; тук е съзнателно решение на курса, за да използва revisor възможностите, които преподава.
cargo tree показва цялото дърво. Това е начинът да откриеш транзитивни зависимости: crate-ове, които не си добавил директно, но които са дошли, защото друга зависимост ги изисква. Не е непременно знак за проблем. Е инструмент, за да отговориш на „кой носи тази версия?“, „защо се компилира толкова много код?“ или „защо има две версии на този crate?“.
cargo update обновява в рамките на ограниченията, които си написал в Cargo.toml. Не е равносилно на „инсталирай последната версия на всичко“ без граници. Преди да обновиш стабилен проект, прегледай какво се е променило в lockfile-а, изпълни тестовете и прочети бележките за версията, когато централна зависимост се промени. Декларираната версия определя приемливия диапазон; lockfile-ът регистрира взетото решение.
По време на разработката cargo check обикновено е по-бърз от cargo build, защото проверява типове и заемания, без да генерира крайния изпълним файл. Не замества тестовете, но намалява времето за обратна връзка, докато редактираш една функция. За да поддържаш качеството на целия проект, използвай тази последователност:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
cargo fmt --check потвърждава формата, без да променя файлове. cargo clippy --all-targets -- -D warnings преглежда библиотеката, бинарния файл и тестовете и превръща предупрежденията му в провали, за да не трупа проектът известен дълг. cargo test проверява поведението. Това са три различни сигнала: последователен формат, идиоматична употреба и очаквано поведение.
Грешката, която ще видиш
E0603: името съществува, но не е част от API
E0603 се появява, когато Rust е намерил item-а, който си назовал, но пътят се опитва да премине частна граница. Диагностиката на фигура 6.2 дава три следи: сочи незаконното използване, казва, че функцията е частна, и посочва къде е декларирана. Това е различно от правописна грешка като „не е намерена тази функция“; тук Rust знае коя функция си искал да използваш.
Поправката зависи от дизайна. Ако функцията трябва да е част от интерфейса, декларирай я pub. Ако трябва да служи само на съседен модул, помисли да преместиш операцията в по-подходящ модул собственик или да изложиш публична функция от по-високо ниво. Ако трябва да я ограничиш до crate-а, pub(crate) изразява по-добре, че никой извън пакета не бива да зависи от нея.
В revisor помощните функции на reporte.rs, като etiqueta, tiempo и detalle, си остават частни. Публичният API е tabla и json, защото това са операциите, които бинарният файл и всеки потребител могат да поискат. Изборът не позволява интеграционни тестове или бъдещ код да зависят от един вътрешен етикет и да замразят случайно решение за формат.
Червеният тест не е грешка на компилация
Когато едно твърдение се провали, Cargo компилира правилно и после изпълнителят отбелязва теста като неуспешен. Няма да има код EXXXX, защото не е нарушение на статичните правила на компилатора. Ще видиш името на теста, лявата и дясната стойност, ако си използвал assert_eq!, и всяко допълнително съобщение, което си добавил.
Това е важна разлика в диагностиката. Грешките на rustc ти казват, че програмата не може да бъде построена според правилата на езика. Един червен тест ти казва, че програмата е била построена, но е нарушила поведението, което си декларирал. Не поправяй червен тест, като махаш твърдението или променяш очакваната стойност, без да прегледаш кой договор е трябвало да се спазва.
Предизвикай провал нарочно веднъж. В теста falla_sin_codigo смени временно очаквания текст на "OK: x" и изпълни съответния филтър. Трябва да видиш как тестът се зачервява. После възстанови правилното поведение. Тест, който никога не си виждал да се проваля, може да покрива друг клон от този, който мислиш, или да твърди нещо твърде слабо, за да открие регресия.
Какво се прави погрешно
Всичко да е pub
Отварянето на всеки struct, поле и функция обикновено започва като бърз начин да се победи E0603. Резултатът е библиотека без граници: всеки модул може да се опре на вътрешни подробности и всяка промяна изисква преглед на много повече извиквания от необходимите. Публикувай операции, които представят възможности на домейна, не всяка помощна стъпка, с която ги имплементираш.
Практичната алтернатива не е да налучкаш съвършения API от първия ден. Дръж частно това, което още няма ясен потребител. Когато друг модул се нуждае от операция, отвори минималния интерфейс и остави реалната употреба да води дизайна.
Организиране с неясни имена като utils или helpers
Папка, наречена utils, не описва отговорност; описва, че някой не е знаел къде да постави нещо. С времето натрупва преобразуване на текст, достъп до файлове, форматиране, HTTP и функции, които никой не смее да премести. Търсенето на код става по-бавно, а зависимостта между модулите става произволна.
В revisor едно правило за YAML живее в config, един превод на състояние живее в reporte, а речникът на домейна живее в modelo. Ако една функция не се побира в нито един модул, първо попитай дали не липсва понятие със собствено име. Често новото име разкрива отговорност, която е била смесена.
Тестване само на здравия път
Тест, който проверява 200 OK, е необходим, но не стига за revisor на услуги. Трябва да са покрити също HTTP 500, невалиден URL, липсващ файл, време на изчакване, празен списък и непознат формат. Грешките не са невероятни изключения в този домейн: те са част от това, което програмата съществува, за да докладва.
Не превръщай всеки външен провал в тест с реална мрежа. revisor използва фалшив локален сървър, за да възпроизвежда известни отговори. Така тества собственото си поведение, не наличността на чужда услуга.
Използване на unwrap(), за да се пишат по-кратки тестове
unwrap() е разумен за подготовка на данни, които самият тест контролира, като литерален YAML, който трябва да е валиден. Ако този YAML се провали, тестът е лошо построен и спирането е правилно. Не го използвай върху резултата, който се опитваш да тестваш. Ако искаш да покажеш, че validar отхвърля един вход, използвай is_err, unwrap_err или matches! според договора.
Правилото е да се различава подготовката от проверката. При подготовката expect("el YAML de la prueba es válido") дава полезен контекст. При проверката едно твърдение изразява точно свойството, което искаш да поддържаш.
Доверяване на изходния код на cargo test, без да се чете броят
Филтър без съвпадения може да върне успех, защото не е имало тестове, които да се провалят. Нов crate може да се компилира без тестове. Лошо разположена интеграция може да не бъде открита. Чети running N tests, имената, които се появяват, и крайното резюме. Полезният резултат не е само „излезе нула“; е „изпълни се тестът, който очаквах, и мина“.
Обновяване на зависимости, без да се прегледа lockfile-ът
cargo update може да промени няколко транзитивни зависимости, макар да си поискал само едно обновяване. Това не го прави опасно само по себе си, но изисква преглед. Виж промяната в Cargo.lock, разбери кои crate-ове са се обновили и изпълни целия набор от тестове. Версия, съвместима на теория, може да разкрие крехко предположение или да промени значително времената на компилация.
Упражнения
Упражнение 1 — Раздели един доклад, без да отваряш повече от необходимото
Създай програма с модул reporte. Трябва да излага публична функция resumen(nombre, sano), която връща String с името и етикета OK или FALLA. Функцията, която решава етикета, трябва да остане частна. От main отпечатай два реда: един здрав и един неуспешен.
Упражнение 2 — Тествай всички варианти на състоянието
Напиши функция es_sano(&Estado) -> bool за четирите варианта на Estado на revisor: Ok, Lento, Falla и NoIntentado. Добави по един тест за вариант. Използвай assert! или assert!(!...) и именувай всеки тест според правилото, което проверява.
Упражнение 3 — Интеграция, която не познава вътрешни подробности
В programas/revisor прочети tests/integracion.rs. Добави интеграционен тест, който използва изключително revisor::modelo и revisor::revisar. Трябва да използва споделения локален сървър и да провери, че revisar_todos връща същия брой състояния като услуги, дори когато една получава HTTP 500. Напиши го, преди да прочетеш тестовете, които tests/integracion.rs вече носи, и после сравни: какво проверява твоят, което другите не проверяват?
Упражнение 4 — Направи червен тест и го върни зелен
Избери съществуващ тест от config.rs или reporte.rs. Смени временно едно очакване, за да се провали, изпълни само този тест с cargo test nombre_de_la_prueba, прочети диагностиката и възстанови правилното поведение. Накрая изпълни cargo fmt --check, cargo clippy --all-targets -- -D warnings и cargo test.
Решения
Решение 1
mod reporte {
fn etiqueta(sano: bool) -> &'static str {
if sano {
"OK"
} else {
"FALLA"
}
}
pub fn resumen(nombre: &str, sano: bool) -> String {
format!("{nombre}: {}", etiqueta(sano))
}
}
fn main() {
println!("{}", reporte::resumen("catalogo", true));
println!("{}", reporte::resumen("pagos", false));
}
etiqueta не се нуждае от pub, защото само resumen я използва. Публичната функция предава резултата, от който main се нуждае, не междинната подробност.
Решение 2
#[derive(Debug)]
enum Estado {
Ok,
Lento,
Falla,
NoIntentado,
}
fn es_sano(estado: &Estado) -> bool {
matches!(estado, Estado::Ok | Estado::Lento)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ok_es_sano() {
assert!(es_sano(&Estado::Ok));
}
#[test]
fn lento_es_sano() {
assert!(es_sano(&Estado::Lento));
}
#[test]
fn falla_no_es_sana() {
assert!(!es_sano(&Estado::Falla));
}
#[test]
fn no_intentado_no_es_sano() {
assert!(!es_sano(&Estado::NoIntentado));
}
}
Четирите теста не са излишни. Функцията съдържа две групи варианти и всеки изразява решение на домейна. Ако някой промени matches! непълно, поне един тест идентифицира кое състояние е загубило значението си.
Решение 3
#[tokio::test]
async fn revisar_todos_conserva_un_estado_por_servicio() {
let d = comun::servidor_demo();
let cliente = reqwest::Client::new();
let servicios = vec![
servicio("bien", &d, "/ok", 2000),
servicio("mal", &d, "/error", 2000),
];
let estados = revisar_todos(&cliente, &servicios, 2).await;
assert_eq!(estados.len(), servicios.len());
assert!(estados[0].esta_bien());
assert!(!estados[1].esta_bien());
}
Тестът използва само публични типове и функции на revisor. Локалната помощна функция строи услугите; споделеният сървър контролира отговорите. Не изисква отваряне на никоя частна функция на HTTP клиента.
Решение 4
Първо изпълни конкретен тест, например:
cargo test validar_rechaza_timeout_cero
Смени временно assert!(validar(&[s]).is_err()) с assert!(validar(&[s]).is_ok()). Тестът трябва да се провали. Възстанови is_err() и завърши с:
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
Решението не е да запазиш промяната, която кара теста да мине; е да провериш, че наборът от тестове открива промяна, която нарушава правилото, и после да възстановиш правилното правило.
Как да разбера, че съм успял
rustc --edition 2024 --test fig06_03.rs && ./fig06_03 --test-threads=1отпечатваrunning 3 testsи завършва с3 passed; 0 failed.cargo test validar_rechaza_nombre_repetidoзавършва с тестаconfig::tests::validar_rechaza_nombre_repetido ... ok.cargo test --test integracionизпълнява тестовете, които импортират единствено публичния API на библиотеката.cargo fmt --checkзавършва без чакащи промени във формата.cargo clippy --all-targets -- -D warningsзавършва без предупреждения.cargo testзавършва с резултатиokза библиотеката, бинарния файл и интеграционните тестове.- Можеш да обясниш защо
main.rsимпортираrevisor::{config, reporte, revisar}, а не частни подробности наsrc/lib.rs.
За по-нататъшно четене
- The Rust Programming Language, глава 7: Managing Growing Projects with Packages, Crates, and Modules — консултирано на 2 октомври 2026 г.
- The Rust Programming Language, глава 11: Writing Automated Tests — консултирано на 2 октомври 2026 г.
- The Rust Programming Language, глава 14: More about Cargo and Crates.io — консултирано на 2 октомври 2026 г.
- Официална референция на Cargo: задаване на зависимости — консултирано на 2 октомври 2026 г.
Предпочитате имейл? Пишете ни на hola@habil.mx