Ha valaha vártál 3-4 percet arra, hogy egy közepes méretű Rust workspace tesztjei lefussanak, tudod, miről beszélek. A beépített cargo test remek eszköz kis projekteknél, de ahogy nő a kódbázis, egyre jobban érezhetők a korlátai. Nézzük meg, mi a baj vele, és hogyan old meg (majdnem) mindent a cargo-nextest.
Miért lassú a cargo test nagyobb projekteknél
A cargo test alapból egyetlen teszt-bináris (vagy binárisok sorozata) futtatásáért felel, és bár a tesztek maguk párhuzamosan futnak egy thread pool-ban, van néhány szerkezeti probléma:
- Soros build és futtatás binárisonként: minden
[[test]]target, minden integrációs teszt fájl külön bináris. Acargo testezeket egymás után futtatja le, nem pedig egyszerre. - Nehezen olvasható kimenet: ha egy teszt elszáll, a hibaüzenet gyakran összekeveredik más tesztek stdout/stderr kimenetével, mert alapból ugyanabban a folyamatban, egymást átfedve futnak.
- Nincs beépített timeout kezelés: egy lefagyott teszt (pl. egy rossz
Mutexlock miatt) simán tud órákig futni CI-ban, mire valaki észreveszi. - Retry és flaky teszt kezelés hiánya: ha van egy időzítésfüggő tesztünk, ami néha elbukik, a
cargo testnem tud automatikusan újrapróbálkozni.
A cargo test maga is használ szálakat a tesztfüggvények futtatásához (alapértelmezetten a CPU magok száma szerint), szóval nem "teljesen soros" – de a binárisok közötti ütemezés és a riportolás az, ami sokat ver el az időből nagyobb projekteknél.
Ezekre a problémákra ad választ a cargo-nextest, ami egy teljesen új teszt-futtató motor, kompatibilis a meglévő #[test] és #[tokio::test] annotációkkal, de sokkal okosabb ütemezéssel és sokkal szebb kimenettel.
Telepítés és az első futtatás
A telepítés egyszerű, mert a nextest csak egy Cargo subcommand, semmilyen extra rendszerfüggőség nem kell hozzá:
cargo install cargo-nextest --locked
A --locked flag garantálja, hogy a nextest saját Cargo.lock-jában rögzített dependency verziókkal épül, ami stabilabb telepítést eredményez, ha épp nincs internet-hozzáférésed a legfrissebb crate verziókhoz.
Telepítés után a projekted gyökerében simán lecserélheted a megszokott parancsot:
cargo nextest run
Ez ugyanúgy megkeresi és lefordítja a teszteket, mint a cargo test, de a futtatás fázisa merőben más. Nézzünk egy egyszerű példát, amit ki is próbálhatsz:
// src/lib.rs
pub fn osszead(a: i32, b: i32) -> i32 {
a + b
}
pub fn oszt(a: i32, b: i32) -> Option<i32> {
let Some(nem_nulla) = (b != 0).then_some(b) else {
return None;
};
Some(a / nem_nulla)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn osszead_mukodik() {
assert_eq!(osszead(2, 3), 5);
}
#[test]
fn oszt_nullaval() {
assert_eq!(oszt(10, 0), None);
}
#[test]
fn oszt_normal() {
assert_eq!(oszt(10, 2), Some(5));
}
}
A fenti kódban egy let-else mintát is bevetettem az oszt függvényben – ez az 1.65 óta stabil szintaxis remekül jön jól, ha egy korai kilépést akarsz olvashatóbban megfogalmazni beágyazott if let helyett.
Futtasd le mindkét paranccsal, és hasonlítsd össze a kimenetet – már ennyi tesztnél is látszik a különbség a formázásban.
Párhuzamos futtatás és a riportok összehasonlítása
A cargo test klasszikus kimenete valahogy így néz ki:
running 3 tests
test tests::osszead_mukodik ... ok
test tests::oszt_nullaval ... ok
test tests::oszt_normal ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Ezzel szemben a nextest minden egyes tesztet önálló folyamatban indít el (nem csak szálban!), ami két fontos előnnyel jár:
- Izoláció: ha egy teszt panic-kal vagy szegmentálási hibával elszáll, az nem viszi magával a többi tesztet, és nem szennyezi a globális állapotot (pl. environment variable-öket).
- Valódi párhuzamosság process szinten: a nextest egy ütemezőt futtat, ami a rendelkezésre álló CPU magok alapján osztja szét a teszt-binárisokat és az egyes teszteket, függetlenül attól, hány
[[test]]target van a projektedben.
A kimenet is sokkal olvashatóbb, élő progress bar-ral és összesített idővel:
Starting 3 tests across 1 binary
PASS [ 0.003s] my-crate tests::osszead_mukodik
PASS [ 0.002s] my-crate tests::oszt_nullaval
PASS [ 0.002s] my-crate tests::oszt_normal
------------
Summary [ 0.010s] 3 tests run: 3 passed, 0 skipped
Nagyobb projekteknél (mondjuk 500+ teszt, több crate egy workspace-ben) a különbség nem csak esztétikai – tapasztalatból mondom, hogy a folyamat-alapú izoláció és az okosabb ütemezés miatt a teljes futásidő gyakran 30-50%-kal csökken, főleg ha vannak lassabb integrációs teszteid, amik I/O-ra várnak.
Mivel minden teszt önálló folyamatban fut, a tesztek közötti megosztott, folyamat-szintű állapot (pl. statikus mutable változók unsafe blokkban) nem fog úgy viselkedni, mint a cargo test alatt. Ha a tesztjeid ilyesmire támaszkodnak,érdemes előbb átnézni és tisztázni az izolációs feltételezéseket.
A nextest emellett .config/nextest.toml fájlban engedi finomhangolni a viselkedést, például retry számot flaky tesztekhez:
[profile.default]
retries = 2
slow-timeout = { period = "30s", terminate-after = 2 }
Ez különösen hasznos, ha vannak olyan tesztjeid, amik hálózati vagy fájlrendszeri versenyhelyzetek miatt időnként elbuknak – a retry mechanizmus nélkül a CI pipeline-od feleslegesen pirosra vált egy-egy flaky teszt miatt.
CI/CD integráció GitHub Actions-ben
A nextest CI-ba illesztése minimális változtatást igényel a meglévő workflow-hoz képest. Az alábbi példa egy tipikus GitHub Actions job-ot mutat:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Rust telepítése
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: cargo-nextest telepítése
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- name: Tesztek futtatása
run: cargo nextest run --workspace --profile ci
Az install-action a taiki-e szervezet karbantartott GitHub Action-je, ami előre lefordított binárisokat tölt le a nextest-hez, így nem kell minden CI futásnál újrafordítani a build-eszközt – ez önmagában is jelentős időmegtakarítás.
Érdemes egy külön ci profilt definiálni a .config/nextest.toml-ban, ami például JUnit XML riportot generál (junit.path = "target/nextest/ci/junit.xml"), amit aztán a GitHub Actions vagy más CI rendszer be tud olvasni a teszt-eredmények megjelenítéséhez a pull request felületén.
[profile.ci]
retries = 1
[profile.ci.junit]
path = "junit.xml"
Bónusz: cargo-audit a függőségek biztonsági ellenőrzésére
Ha már finomhangoljuk a CI pipeline-t, érdemes egy másik hasznos eszközt is beszúrni: a cargo-audit-ot. Ez a RustSec adatbázis alapján ellenőrzi a Cargo.lock-ban szereplő függőségeket ismert biztonsági résekre.
Telepítés:
cargo install cargo-audit
Futtatás:
cargo audit
Ha talál sérülékeny crate-et, egy jól olvasható riportot ad vissza a CVE azonosítóval és a javasolt frissítési verzióval. A CI-ba illesztve ez egy külön lépés lehet ugyanabban a workflow-ban:
- name: cargo-audit telepítése
uses: taiki-e/install-action@v2
with:
tool: cargo-audit
- name: Függőségek auditálása
run: cargo audit
Ha egy adott sérülékenységet tudatosan vállalsz (pl. nincs még patch, vagy a kódod nem érintett funkciót használ), a cargo audit engedi kizárni azt egy .cargo/audit.toml konfigurációs fájlban, ignore listával, hogy ne blokkolja feleslegesen a pipeline-t.
Összefoglalás
A cargo-nextest nem varázslat, csak egy okosabb ütemező és futtató motor a már megírt teszteid fölé – nem kell hozzá egyetlen sort sem átírni a teszt kódodban. Cserébe kapsz gyorsabb futásidőt, jobb izolációt, olvashatóbb kimenetet és beépített retry mechanizmust a flaky tesztekhez. Ha ehhez hozzáadod a cargo-audit-ot a CI pipeline-odba, egy csapásra biztonságosabb és megbízhatóbb lesz a build folyamatod – mindkettő telepítése és bevezetése egy délután alatt megoldható, a megtérülés pedig azonnal érezhető minden egyes commit-nál.