Skip to content

Farion-ai/rsspec

Repository files navigation

rsspec

A Ginkgo/RSpec-inspired BDD testing framework for Rust with a closure-based API.

Write expressive, structured tests using describe, context, it, lifecycle hooks, table-driven tests, and more — all in idiomatic Rust.

Quick Start

Add to your Cargo.toml:

[dev-dependencies]
rsspec = "0.7"

[[test]]
name = "my_tests"
harness = false

Write your first spec in tests/my_tests.rs:

fn main() {
    rsspec::run(|ctx| {
        ctx.describe("Calculator", |ctx| {
            ctx.it("adds two numbers", || {
                assert_eq!(2 + 3, 5);
            });

            ctx.context("with negative numbers", |ctx| {
                ctx.it("handles negatives", || {
                    assert_eq!(-1 + 1, 0);
                });
            });
        });
    });
}

Run with cargo test:

Calculator
  ✓ adds two numbers
  with negative numbers
    ✓ handles negatives

PASS
2 passed (0.001s)

Using with #[test] functions

rsspec::run() auto-detects when it's running inside cargo test's harness and adapts: it skips CLI arg parsing and panics on failure instead of calling process::exit.

#[cfg(test)]
mod tests {
    #[test]
    fn calculator_spec() {
        rsspec::run(|ctx| {
            ctx.describe("Calculator", |ctx| {
                ctx.it("adds", || { assert_eq!(2 + 3, 5); });
            });
        });
    }
}

run_inline() is also available as an explicit alternative that never parses CLI args.

Note: When using #[test] mode, the BDD tree output goes to stderr (which cargo test captures by default). Add --show-output or --nocapture to see it: cargo test -- --show-output

API Reference

Containers

Nest your specs with describe, context, or when — they are aliases:

ctx.describe("outer", |ctx| {
    ctx.context("inner", |ctx| {
        ctx.when("something happens", |ctx| {
            ctx.it("works", || { assert!(true); });
        });
    });
});

Focus — only run focused containers and their children:

ctx.fdescribe("only this runs", |ctx| {
    ctx.it("focused by inheritance", || { /* runs */ });
});

Variants: fdescribe, fcontext, fwhen

Pending — skip entire containers:

ctx.xdescribe("not yet implemented", |ctx| {
    ctx.it("skipped", || { /* never runs */ });
});

Variants: xdescribe, xcontext, xwhen

Specs

Individual test cases use it or specify:

ctx.it("does something", || {
    assert_eq!(1 + 1, 2);
});

ctx.specify("also works", || {
    assert!(true);
});

Focus: fit, fspecifyPending: xit, xspecify

Note: Test closures must be Fn() (not FnOnce) to support retries and must_pass_repeatedly. If you need to move a non-Copy value into a test closure, wrap it in an Rc or use clone().

Lifecycle Hooks

Hook Runs Scope
before_each Before every it Inherited by nested scopes
just_before_each After all before_each, right before the body Inherited
after_each After every it (even on panic) Inherited
before_all Once before all tests in scope Per describe (not inherited)
after_all Once after all tests in scope Per describe (not inherited)
ctx.describe("database tests", |ctx| {
    ctx.before_all(|| {
        // expensive setup — runs once
    });

    ctx.before_each(|| {
        // runs before every test
    });

    ctx.after_each(|| {
        // runs after every test (even on panic)
    });

    ctx.after_all(|| {
        // cleanup — runs once after all tests
    });

    ctx.it("uses the database", || {
        assert!(true);
    });
});

Execution order per test:

before_all (once) -> before_each -> just_before_each -> body -> after_each -> after_all (once)

Hook details:

  • Inheritance: before_each, after_each, and just_before_each are inherited by nested describe/context blocks. before_all and after_all only run in the scope where they are defined.
  • Ordering: before_each hooks run outer-to-inner. after_each hooks run inner-to-outer. Both are guaranteed to run even if a prior hook or the test body panics.
  • Multiple hooks: You can register multiple hooks of the same type in the same scope. They all run in registration order.
  • Ordered tests: before_each and after_each from parent describes wrap the entire ordered sequence, not each individual step.
  • Filtering optimization: before_all/after_all are skipped when all children in a scope are filtered out (by labels or focus mode), avoiding unnecessary setup.

Reading fixtures in hooks. A returning before_all/before_each defines a fixture (keyed by type); any hook can read a fixture from an enclosing scope via a &T parameter, exactly like it(|v: &T|). This is the "act once, assert often" seam — build one expensive fixture, then let nested setup derive per-context results from it:

ctx.describe("PURL lookup", |ctx| {
    ctx.before_all(|| -> TestEnv { TestEnv::seed() });        // expensive, once

    ctx.describe("core lookup", |ctx| {
        ctx.before_all(|env: &TestEnv| -> CoreResults {       // reads env, stores results
            CoreResults { affected: env.lookup(purl), fixed: env.fixed(purl) }
        });
        ctx.it("finds affected", |r: &CoreResults| { assert_eq!(r.affected.len(), 1); });
        ctx.it("returns empty for fixed", |r: &CoreResults| { assert!(r.fixed.is_empty()); });
    });

    ctx.after_all(|env: &TestEnv| { env.teardown(); });        // reads env for cleanup
});

One fixture per hook. An async hook can't take a &T param — a borrow can't cross .await — so inside an async body clone the fixture out with rsspec::fixture_cloned::<T>() (or, with the macro layer, just name it and it is cloned in). See Async Tests.

Decorators

Attach metadata to it blocks using the fluent builder API:

ctx.it("tagged test", || { /* ... */ })
    .labels(&["integration", "slow"]);

ctx.it("flaky test", || { /* ... */ })
    .retries(3);

ctx.it("must be stable", || { /* ... */ })
    .must_pass_repeatedly(5);

ctx.it("fast test", || { /* ... */ })
    .timeout(1000);

Decorators can be combined:

ctx.it("everything", || { /* ... */ })
    .labels(&["smoke"])
    .retries(2)
    .timeout(5000);

Decorator details:

  • .labels() accumulates across multiple calls: .labels(&["a"]).labels(&["b"]) results in labels ["a", "b"].
  • .retries(n) retries the test up to n additional times on failure. retries(0) means no retries (same as default).
  • .must_pass_repeatedly(n) requires the test to pass n consecutive times. n must be >= 1.
  • .timeout(ms) fails the test if it exceeds ms milliseconds. Important: The timeout is checked after the closure returns — it cannot abort a running test. If your test deadlocks or enters an infinite loop, the timeout will not fire. Use OS-level timeouts (e.g. CI job timeouts) as a safety net.
  • Composition order: When combined, decorators apply as timeout(must_pass_repeatedly(retries(body))). The timeout wraps the entire retry+must_pass cycle, not individual attempts.

Describe-Level Labels

Add labels to a describe scope — they propagate to all child tests:

ctx.describe("integration tests", |ctx| {
    ctx.labels(&["integration"]);

    ctx.it("inherits labels", || { /* ... */ });
});

Labels accumulate: calling ctx.labels() multiple times adds to the existing set.

Table-Driven Tests

Parameterized specs with describe_table:

ctx.describe_table("arithmetic")
    .case("addition", (2i32, 3i32, 5i32))
    .case("large numbers", (100, 200, 300))
    .case("negative", (-1, 1, 0))
    .run(|(a, b, expected): &(i32, i32, i32)| {
        assert_eq!(a + b, *expected);
    });

Each case becomes a separate test.

Use case_unnamed for auto-named cases (case_1, case_2, ...):

ctx.describe_table("squares")
    .case_unnamed((2i32, 4i32))
    .case_unnamed((3, 9))
    .case_unnamed((4, 16))
    .run(|(input, expected): &(i32, i32)| {
        assert_eq!(input * input, *expected);
    });

Type safety: The first .case() call fixes the data type T for all subsequent cases. Mixing types is a compile-time error. Always annotate the first case's type explicitly (e.g. 2i32 not 2) to avoid Rust's default integer inference.

Ordered Tests

Sequential, fail-fast test workflows:

ctx.ordered("user registration", |oct| {
    oct.step("create account", || {
        // ...
    });
    oct.step("verify email", || {
        // ...
    });
});

All steps run in sequence. If any step fails, subsequent steps are skipped. Steps are numbered in the output (e.g. [1/2] create account).

Use ordered_continue_on_failure to run all steps regardless:

ctx.ordered_continue_on_failure("resilient workflow", |oct| {
    oct.step("step 1", || { /* ... */ });
    oct.step("step 2", || { /* runs even if step 1 fails */ });
});

Macro layer (optional)

A proc-macro layer (the macros feature, on by default) trims the closure ceremony — and gives a before_all!-declared fixture an implicit binding: declare it once with a name, then read it by that bare name in every later body. No per-it |v: &T|. The macros lower to the same builder calls as the closure API and interoperate with it inside the same run / run_inline body, so you can adopt them gradually and drop back to closures any time. Import with use rsspec::*; (or per name); opt out with default-features = false.

use rsspec::*;

fn main() {
    rsspec::run(|_| {
        describe!("Calculator", {
            before_all!(base: i32 = 10);              // declared once — named + typed

            it!("uses the fixture", {                 // `base` is implicit, no |base: &i32|
                assert_eq!(*base, 10);
            });
            it!("does setup work", {                  // ordinary block body
                let v = vec![1, 2, 3];
                assert_eq!(v.len(), 3);
            });

            context!("when slow", {
                it!("is tagged and retried", { /* … */ }, tags = ["slow"], retries = 2);
            });
        });
    });
}

Specsit! / specify!, focused fit! / fspecify!, pending xit! / xspecify!. Body forms: a { block } (in-scope fixtures read implicitly by name), an explicit |v: &T| read (the runtime hands the reference in), or async { … } (requires the tokio feature — in-scope fixtures are read here too, cloned in so they survive .await; one it! replaces the async_* methods). Trailing decorators in any order: tags=[..], retries=N, timeout=MS, must_pass_repeatedly=N.

Containersdescribe! / context! / when!, focused fdescribe! / fcontext! / fwhen!, pending xdescribe! / xcontext! / xwhen!.

Hooksbefore_all! / before_each! take the fixture form before_all!(name: T = expr) (read later by the bare name), a read-and-return form before_all!(|env: &U| -> T { .. }) that reads an enclosing-scope fixture and stores its result, or a { block } side-effect form. after_each! / after_all! / just_before_each! take a { block } or a read form after_all!(|env: &T| { .. }).

Note: A before_all!(name: T = …) fixture is read by its bare name in later bodies — the read side never restates the type. The explicit |v: &T| form stays available when you'd rather name the reference; two in-scope fixtures of the same type are a compile error, since an implicit read can't tell them apart. Everything lowers to the closure API; the two styles are equivalent and mixable.

Async Tests

Enable the tokio feature for async test support:

[dev-dependencies]
rsspec = { version = "0.7", features = ["tokio"] }
tokio = { version = "1", features = ["full"] }

Write async tests with async_it:

ctx.describe("API client", |ctx| {
    ctx.async_it("fetches data", || async {
        let data = fetch_data().await;
        assert!(!data.is_empty());
    });
});

All decorators work with async tests:

ctx.async_it("flaky network call", || async {
    let resp = call_api().await;
    assert!(resp.is_ok());
})
.retries(3)
.timeout(5000)
.labels(&["integration"]);

Async hooks — including value-returning ones that build a fixture once. A connection pool created in an async before_all stays usable in later hooks and tests because the whole subtree shares one runtime (see below):

ctx.describe("with async setup", |ctx| {
    // Build the pool once, on the suite runtime, and store it as a fixture.
    ctx.async_before_all(|| async {
        PgPool::connect("postgres://localhost/test").await.unwrap()
    });

    ctx.async_it("uses the pool", || async {
        // Clone the pool out of the fixture, then await on it —
        // a `&T` cannot be held across `.await`, but `PgPool` is `Clone`.
        let pool = rsspec::fixture_cloned::<PgPool>();
        let rows = sqlx::query("SELECT 1").fetch_all(&pool).await.unwrap();
        assert_eq!(rows.len(), 1);
    });
});

With the macro layer the same fixture is declared inline and read implicitly:

describe!("with async setup", {
    before_all!(pool: PgPool = async {
        PgPool::connect("postgres://localhost/test").await.unwrap()
    });
    it!("uses the pool", async {
        // `pool` is the enclosing fixture, cloned in — own it across `.await`.
        let rows = sqlx::query("SELECT 1").fetch_all(&pool).await.unwrap();
        assert_eq!(rows.len(), 1);
    });
});

Async ordered steps and table-driven tests:

ctx.ordered("async workflow", |oct| {
    oct.async_step("create resource", || async { create().await; });
    oct.async_step("verify resource", || async { verify().await; });
});

ctx.describe_table("async endpoints")
    .case("users", "/api/users".to_string())
    .case("posts", "/api/posts".to_string())
    .async_run(|endpoint: &String| {
        let url = endpoint.clone();
        async move {
            let resp = reqwest::get(&url).await.unwrap();
            assert!(resp.status().is_success());
        }
    });

You can also use rsspec::async_test() directly to wrap any async closure:

ctx.it("manual async", rsspec::async_test(|| async {
    assert!(true);
}));
Sync Async
it / fit / xit async_it / async_fit / async_xit
specify / fspecify / xspecify async_specify / async_fspecify / async_xspecify
before_each / after_each async_before_each / async_after_each
before_all / after_all async_before_all / async_after_all
just_before_each async_just_before_each
step (ordered) async_step
run (table) async_run

Async runtime details:

  • All async hooks and tests in a subtree share one lazily-built single-threaded Tokio runtime (new_current_thread().enable_all()), created on first async use and dropped when the subtree finishes (one per worker thread under parallel). A resource bound to the runtime — a pool, a listener, a spawned task — therefore survives from one hook to the next. A subtree with no async work builds no runtime.
  • Because it is current_thread and runs on the runner thread, fixture reads are valid inside async bodies. A &T fixture cannot be held across .await; clone out what you need first with rsspec::fixture_cloned::<T>() (pools and channel senders are Clone) — or, with the macro layer, just name the fixture and it is cloned in.
  • tokio::spawn() works but runs on the same thread — there is no multi-threaded parallelism within a single test.
  • Do not create a nested Tokio runtime inside an async test. Calling Runtime::new() (or block_on) inside an async_it block will panic with "Cannot start a runtime from within a runtime."
  • The || async { ... } pattern (closure returning a future) is required because Rust's async Fn() trait is not yet stable.

Parallel Execution

By default rsspec runs every spec sequentially on one thread. Enable the parallel feature to run distinct top-level subtrees on a pool of worker threads:

[dev-dependencies]
rsspec = { version = "0.7", features = ["parallel"] }
# Pick a worker count at runtime (CLI flag, env var, or `auto` for core count):
cargo test --test my_spec --features parallel -- --parallel=4
RSSPEC_PARALLEL=auto cargo test --test my_spec --features parallel

The unit of parallelism is one top-level describe / it / ordered node. Each runs entirely on a single worker, so before_all still executes once per subtree and fixtures stay isolated — exactly like Jest runs files in parallel but specs within a file in order. Output is buffered per subtree and flushed in tree order, so it stays deterministic regardless of which worker finishes first.

The parallel feature adds a Send bound to every test and hook closure so they can move onto worker threads. This is gated behind the feature precisely because a dyn Fn trait object bakes its auto-traits into its type — the requirement cannot depend on the runtime worker count. With the feature off, the API is unchanged and !Send bodies (capturing Rc/RefCell) still compile.

Caveats:

  • Only distinct top-level subtrees run concurrently; specs within a subtree stay serial.
  • Tests sharing process-global mutable state (static mut, shared atomics across subtrees, env vars, the current directory, shared files/ports) can race. Group them under one top-level describe, or run sequentially.
  • Tests that install their own panic hook via std::panic::set_hook / take_hook are unsupported under --parallel: the panic hook is process-global and shared across all workers, so it races with rsspec's own hook and with other subtrees' panics.
  • --filter, focus mode (fit/fdescribe), labels, and xit/pending apply identically.
  • Async is unaffected — each async test still builds its own current-thread runtime inside the test body.
  • The buffered tree output is deterministic; stderr (ordered-step echoes, retry notices) still interleaves.
  • Without the parallel feature, --parallel/RSSPEC_PARALLEL are accepted but warn and run sequentially.

Running under cargo test and cargo nextest

rsspec runs under both the standard test runner and cargo nextest. The interesting question with nextest is granularity, and rsspec answers it from your fixtures rather than asking you to choose.

We isolate acts, not assertions. nextest's model is one process per test. For "arrange & act once, assert often", isolating per assertion would be wrong — it would re-run the expensive act for every assertion. So the unit of process-isolation is the scope that owns the shared setup, not the individual it.

Concretely, the isolation unit is the shallowest scope on the path to a spec that declares a before_all/after_all (before_each/after_each are per-spec and don't force co-location). That subtree runs as one nextest test:

  • Seam / e2e — a before_all at the top (seeded DB, warm cache, HTTP app shared across scenarios) → the suite is one nextest test. You get cross-binary isolation, suite-level retries (a flaky act re-runs act + asserts together — the correct unit), and reporting, while the act still runs exactly once.
  • Integrationbefore_each per scenario, no shared before_all → each spec is independent, so each becomes its own nextest test with full per-test isolation, --partition sharding, and retries.
  • Unit — table-driven / pure specs → each case is its own nextest test.

You never pick the boundary; where you declare shared state decides it. Independent specs get per-test isolation for free; specs that share an act stay together because they must.

In nextest, each isolation root is one row in the summary/JUnit; the per-it breakdown is in the captured output (rsspec's tree), shown on failure or with --nocapture. For the rich, streaming BDD tree during local development, run the target as harness = false and use cargo test — the output is identical to running the binary directly.

Sharing a connection pool in a before_all is the one case where infra is inevitably shared state: those specs pin to one process by design (re-creating the pool per process is the alternative — correct, just slower). That's a knob you control by where you put the pool.

Runtime Helpers

defer_cleanup

Register LIFO cleanup functions (like Go's defer):

ctx.it("creates temp resources", || {
    rsspec::defer_cleanup(|| {
        // cleanup runs after this test, even on panic
    });
});

Note: defer_cleanup uses a thread-local stack. Calling it from a std::thread::spawned thread inside a test will register the cleanup on the wrong thread. Keep cleanup registrations on the test thread.

by

Document steps within a test:

ctx.it("complex workflow", || {
    rsspec::by("setting up prerequisites");
    // ...
    rsspec::by("performing the action");
    // ...
    rsspec::by("verifying the result");
    assert!(true);
});

Each step prints STEP: description to stderr.

skip!

Skip a test at runtime:

ctx.it("requires a database", || {
    if !db_available() {
        rsspec::skip!("database not available");
    }
    // ... test body ...
});

Environment Variables

Variable Description
RSSPEC_LABEL_FILTER Filter tests by labels — boolean grammar + globs (see Label filtering). Overridden by --label-filter.
RSSPEC_FAIL_ON_FOCUS Set to 1 or true to fail when focused tests exist (CI safety)
RSSPEC_PARALLEL Worker count for the parallel feature: a positive integer or auto. Overridden by --parallel. Ignored (warns) without the feature
NO_COLOR Disable colored output

Label filtering

Attach labels with .labels(&[...]) on a spec, or ctx.labels(&[...]) on a describe (inherited by all child specs). Filter at run time with the --label-filter CLI flag or the RSSPEC_LABEL_FILTER env var (the flag wins):

cargo test --test seam -- --label-filter "lang:* && !pg:slow"
RSSPEC_LABEL_FILTER="integration || smoke" cargo test

The filter is a boolean expression:

  • &&, ||, !, and parentheses — (lang:async || lang:plain) && !pg:slow
  • glob atoms with *lang:* matches lang:async and lang:plain-call
  • precedence: ! > && > ||

The legacy syntax still works: a,b (OR), a+b (AND), !x (exclude). A spec's effective labels are its own plus every ancestor describe's. An invalid expression matches nothing and prints a warning.

Shared State Patterns

Since hooks and tests use Fn() + 'static closures, sharing mutable state requires thread-safe types. Here are the recommended patterns:

Static atomics (simple counters/flags)

use std::sync::atomic::{AtomicU32, Ordering};

ctx.describe("with counter", |ctx| {
    static COUNTER: AtomicU32 = AtomicU32::new(0);

    ctx.before_each(|| {
        COUNTER.fetch_add(1, Ordering::SeqCst);
    });

    ctx.it("counter incremented", || {
        assert!(COUNTER.load(Ordering::SeqCst) >= 1);
    });
});

OnceLock (expensive one-time setup)

use std::sync::OnceLock;

ctx.describe("with shared resource", |ctx| {
    static POOL: OnceLock<DbPool> = OnceLock::new();

    ctx.before_all(|| {
        POOL.set(create_pool()).unwrap();
    });

    ctx.it("uses the pool", || {
        let pool = POOL.get().unwrap();
        // ... use pool ...
    });
});

CI Integration

GitHub Actions example

- name: Run tests
  env:
    RSSPEC_FAIL_ON_FOCUS: "1"  # Fail if any fit/fdescribe slipped through
    NO_COLOR: "1"               # Clean CI logs
  run: cargo test

- name: Run integration tests only
  env:
    RSSPEC_LABEL_FILTER: "integration"
  run: cargo test

Splitting test stages with labels

ctx.describe("API", |ctx| {
    ctx.labels(&["integration"]);
    // These only run when RSSPEC_LABEL_FILTER includes "integration"
    ctx.it("creates users", || { /* ... */ });
});

ctx.describe("Utils", |ctx| {
    ctx.labels(&["unit"]);
    // These only run when RSSPEC_LABEL_FILTER includes "unit"
    ctx.it("parses input", || { /* ... */ });
});

Then in CI:

RSSPEC_LABEL_FILTER=unit cargo test          # Fast PR checks
RSSPEC_LABEL_FILTER=integration cargo test   # Nightly / staging

Migrating from #[test]

rsspec can coexist with standard #[test] functions. Migrate incrementally:

  1. Start with #[test] + rsspec::run() — no Cargo.toml changes needed:

    #[test]
    fn user_api_spec() {
        rsspec::run(|ctx| {
            ctx.describe("User API", |ctx| {
                ctx.it("creates users", || { /* ... */ });
            });
        });
    }
  2. When you want full BDD output, add a [[test]] entry with harness = false:

    [[test]]
    name = "user_api"
    harness = false

    Then change the test file to use fn main() instead of #[test].

  3. Keep unit tests as #[test] — rsspec is most valuable for integration and acceptance tests where nesting, hooks, and lifecycle management matter.

googletest Integration

Enable the googletest feature for composable matchers:

[dev-dependencies]
rsspec = { version = "0.7", features = ["googletest"] }
use rsspec::matchers::*;

fn main() {
    rsspec::run(|ctx| {
        ctx.describe("with matchers", |ctx| {
            ctx.it("has elements", || {
                let v = vec![1, 2, 3];
                assert_that!(v, not(empty()));
            });
        });
    });
}

License

Licensed under either of Apache License, Version 2.0 or MIT license at your option.

About

No description, website, or topics provided.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages