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. A cargo test ezeket 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 Mutex lock 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 test nem tud automatikusan újrapróbálkozni.
Megjegyzés

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
Tipp

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:

  1. 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).
  2. 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.

Figyelem

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.

Jó tudni

É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
Tipp

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.