Cél és előfeltételek
Ebben a leckében automatikus tesztekkel írod le egy kis szószámláló szerződését. Az előző fájlkezelési gyakorlat és az Option–Result lecke ismeretét használjuk. A végére külön tudod választani a tiszta számítást, a nyilvános könyvtári felületet és a dokumentált használati példát. A teszt feladata nem pusztán a zöld jelzés előállítása: egy későbbi hibás módosításnak el kell buknia rajta.
Előbb fogalmazd meg a viselkedést: az üres szöveg nulla szót tartalmaz; az egymást követő elválasztók nem számítanak szavaknak; egy olvashatatlan fájl nem nulla szavas siker. Ezekből külön esetek következnek. Ha csak egyetlen kényelmes mondatot próbálsz ki, egy elrontott szélsőérték-kezelés könnyen észrevétlen marad. A hibajelzés is a szerződés része, ezért a sikertelen ágat ugyanúgy meg kell figyelned.
Három teszthatár egy kis projektben
A Rust Book tesztszervezési fejezete alapján az egységtesztek gyakran a vizsgált modul mellett helyezkednek el. Hozzáférhetnek annak belső elemeihez is. A tests könyvtár integrációs tesztjei külön crate-ként fordulnak, és a könyvtár nyilvános felületét használják. Ez a határ különösen hasznos: észreveheted, ha egy szükséges műveletet véletlenül nem tettél elérhetővé a felhasználóknak.
A dokumentációs teszt a használati példát fordítja és ellenőrzi. Nem az összes hibás eset felsorolására való; azt mutassa meg, hogyan hívja meg valaki a könyvtárat. Bináris programokat és folyamatokat külön is lehet integrációsan vizsgálni. Ebben a leckében a fájlt ténylegesen olvasó könyvtári API a vizsgált külső határ, ezért nem állítjuk, hogy ezzel már a következő lecke parancssori kimenetét is leteszteltük.
Hozz létre új szovegteszt nevű könyvtári projektet: cargo new --lib szovegteszt. A Cargo.toml csomagneve legyen szovegteszt, az edition 2024, és ne adj hozzá külső függőséget. Az alábbi teljes tartalom kerüljön az src/lib.rs fájlba. A dokumentációban négy szóközzel behúzott rész Rust-kódpélda; ebből lesz a dokumentációs teszt.
use std::fs;
use std::io;
use std::path::Path;
/// Whitespace alapján számol, az írásjeleket megtartja.
///
/// use szovegteszt::word_count;
/// assert_eq!(word_count("alma körte"), 2);
pub fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
pub fn count_file(path: &Path) -> io::Result<usize> {
let text = fs::read_to_string(path)?;
Ok(word_count(&text))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_has_no_words() {
assert_eq!(word_count(""), 0);
}
#[test]
fn several_separators_count_as_one_boundary() {
assert_eq!(word_count(" alma\t\nkörte "), 2);
}
}
A word_count függvény környezetfüggetlen, így sok esetet gyorsan tudunk vele ellenőrizni. A count_file ezzel szemben tényleges I/O-műveletet végez. A tesztbeli unwrap_err tudatos állítás: az adott bemenetre hibát várunk, és a teszt bukjon meg, ha a művelet váratlanul sikerül. Az éles fájlkezelésben ugyanez a rövidítés nem lenne megfelelő felhasználói hibakezelés.
Elkülönített próbaadatok
A tesztfájlokat ne a projekt gyökerében és ne egy állandó dokumentumnév alatt hozd létre. Egy párhuzamos futás ugyanazt az állapotot módosíthatná, és kiszámíthatatlan bukást okozna. Az alábbi Fixture csak frissen létrehozott ideiglenes könyvtárat birtokol. A create_dir meglévő könyvtárnál hibázik: ilyen helyzetben nem használunk fel és nem törlünk idegen tartalmat.
A folyamatazonosító és az atomi számláló elkülöníti a szokásos párhuzamos teszteseteket. Ez nem általános, támadóval szemben megerősített ideiglenesfájl-könyvtár, hanem helyi gyakorlósegéd. A Drop normál leálláskor és szokásos tesztpánik miatti veremvisszabontáskor takarít; kényszerített folyamatleállítás után maradhat ideiglenes könyvtár. A törlés hibáját a segéd nem teszi külön tesztbukássá, ezért a takarítás eredménye nem része az API bizonyított viselkedésének.
Gyakorlófeladatok
1. feladat: értelmes határesetek
Készíts négyes esettáblát az üres bemenethez, a whitespace szöveghez, az ékezetes szavakhoz és a vesszővel összekötött szöveghez! A vesszőt itt nem választjuk le: kizárólag whitespace mentén bontunk. Így a teszt nem nyelvészeti szófelismerést, hanem egy pontosan megnevezett feldolgozási szabályt ellenőriz.
Megoldás
Ez a blokk önálló tesztforrás is: rustc --edition=2024 --test pelda.rs -o pelda-teszt, majd futtasd a létrejött tesztprogramot.
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
#[test]
fn boundary_cases() {
for (input, expected) in [("", 0), (" \n\t", 0), ("árvíz tűrő", 2), ("alma,körte", 1)] {
assert_eq!(word_count(input), expected, "bemenet: {input:?}");
}
}
A bemenetet a hibás állítás üzenetében is megőrizzük, így egy táblázatos tesztnél azonnal azonosítható az elbukott sor. A várt értékek kézzel megadott szerződésből származnak; nem ugyanazzal az algoritmussal számoljuk ki őket, amelyet vizsgálunk.
2. feladat: valódi fájl és hibás tartalom
Írj három integrációs esetet: helyes UTF-8 fájl, nem létező fájl és érvénytelen UTF-8 tartalom! Minden eset külön könyvtárat használjon. Az alábbi projektrészlet a tests/public_api.rs teljes tartalma. Önálló Playground-programként nem fordul: a szovegteszt könyvtárra hivatkozik, ezért a Rust-blokk ezt kifejezetten jelzi.
Megoldás
use std::fs;
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use szovegteszt::count_file;
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> io::Result<Self> {
let id = NEXT.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir()
.join(format!("rusthu-fixture-{}-{id}", std::process::id()));
fs::create_dir(&path)?;
Ok(Self(path))
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn reads_a_real_utf8_file() -> io::Result<()> {
let fixture = Fixture::new()?;
let path = fixture.0.join("input.txt");
fs::write(&path, " árvíz\nkörte ")?;
assert_eq!(count_file(&path)?, 2);
Ok(())
}
#[test]
fn missing_file_is_not_empty_input() -> io::Result<()> {
let fixture = Fixture::new()?;
let error = count_file(&fixture.0.join("missing.txt")).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
Ok(())
}
#[test]
fn rejects_invalid_utf8() -> io::Result<()> {
let fixture = Fixture::new()?;
let path = fixture.0.join("binary.bin");
fs::write(&path, [0xff])?;
assert_eq!(count_file(&path).unwrap_err().kind(), io::ErrorKind::InvalidData);
Ok(())
}
A cargo test a projekt gyökeréből két belső tesztet, három integrációs tesztet és egy dokumentációs példát futtat. Nem helyettesítjük a fájlolvasást szimulált függvénnyel: itt maga a helyi fájlművelet is része az ellenőrzésnek. A rendszerfüggő hibaüzenet helyett a hordozható ErrorKind besorolást hasonlítjuk össze.
3. feladat: bizonyítsd, hogy a teszt hibát is talál
Egy külön másolatban rontsd el a word_count függvényt úgy, hogy mindig egyet adjon vissza! Futtasd újra a teszteket, és nevezd meg, melyik elvárás sérült. Ezután állítsd vissza a valódi implementációt. A negatív próba segít felismerni a véletlenül ki nem választott tesztcélt vagy a semmitmondó állítást.
Megoldás
Az üres bemenet tesztje elbukik, mert nulla helyett egyet kap. A whitespace és a két szavas esetek is hibát jelezhetnek. A visszaállítás után a teljes cargo test legyen sikeres. Ha a hibás változat is zöld, ellenőrizd, hogy a megfelelő projektből futtattál-e, és nem szűrted-e ki a teszteket egy névrészlettel.
Elfogadási feltételek
- A cargo test két egységtesztet, három integrációs tesztet és egy dokumentációs tesztet sikeresen futtat a megadott projektben.
- A fájlos esetek külön ideiglenes könyvtárban dolgoznak, és az érvénytelen UTF-8 vagy hiányzó fájl nem válik sikeres nulla eredménnyé.
- A szándékosan mindig egyet visszaadó változat legalább az üres bemenet állításán elbukik; a javított változat ismét zöld.
Összefoglaló és továbblépés
A különböző tesztfajták eltérő határt védenek, ezért együtt hasznosak. A Cargo tesztparancs leírása alapján később külön célokat is futtathatsz, de most maradjon a teljes ellenőrzés az alap. A CLI miniprojektben a kiszámított értékek mellett már a tényleges folyamat kilépését és kimenetét is meg kell vizsgálnod.