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

  1. A cargo test két egységtesztet, három integrációs tesztet és egy dokumentációs tesztet sikeresen futtat a megadott projektben.
  2. 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é.
  3. 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.