Skip to content

brianp/spectacular

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Spectacular

An RSpec-inspired test framework for Rust

Stackable before/after hooks with three layers of control.

crates.io docs.rs MIT License


Spectacular gives your Rust tests three layers of hooks that stack in a predictable order:

Layer Runs once per... Runs per test
Suite binary (before) test (before_each / after_each)
Group group (before / after) test (before_each / after_each)
Test -- the test body

Installation

[dev-dependencies]
spectacular = "0.1"

Quick Start

RSpec-style DSL

use spectacular::spec;

spec! {
    describe "arithmetic" {
        it "adds two numbers" {
            assert_eq!(2 + 2, 4);
        }

        it "multiplies two numbers" {
            assert_eq!(3 * 7, 21);
        }
    }
}

describe "string" slugifies the string into a module name ("arithmetic"mod arithmetic). You can also use mod name directly if you prefer.

Attribute style

use spectacular::test_suite;

#[test_suite]
mod arithmetic {
    #[test]
    fn adds_two_numbers() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    fn multiplies_two_numbers() {
        assert_eq!(3 * 7, 21);
    }
}

Hooks

Group hooks

Group hooks run within a single test module. before runs once before the first test; after runs once after the last test. before_each and after_each run around every test.

use spectacular::spec;
use std::sync::atomic::{AtomicBool, Ordering};

static READY: AtomicBool = AtomicBool::new(false);

spec! {
    describe "with hooks" {
        use super::*;

        before { READY.store(true, Ordering::SeqCst); }
        after  { /* cleanup */ }
        before_each { /* per-test setup */ }
        after_each  { /* per-test teardown */ }

        it "runs after setup" {
            assert!(READY.load(Ordering::SeqCst));
        }
    }
}

Suite hooks (3-layer)

Suite hooks run across all opted-in groups. Place suite! as a sibling of your test groups, then opt in with suite; (DSL) or #[test_suite(suite)] (attribute style):

use spectacular::{suite, spec};
use std::sync::atomic::{AtomicBool, Ordering};

static DB_READY: AtomicBool = AtomicBool::new(false);

suite! {
    before      { DB_READY.store(true, Ordering::SeqCst); }
    before_each { /* per-test transaction */ }
    after_each  { /* rollback */ }
}

spec! {
    describe "database tests" {
        use super::*;
        suite;

        it "has database access" {
            assert!(DB_READY.load(Ordering::SeqCst));
        }
    }
}

Groups without suite; skip the suite layer entirely -- no runtime cost, no coupling.

Context Injection

Hooks can produce context values that flow naturally to tests and teardown hooks, eliminating thread_local! + RefCell patterns.

  • before → shared &T: When before returns a value, it's stored in an OnceLock<T>. Tests, before_each, after_each, and after all receive &T.
  • before_each → owned T: When before_each returns a value, each test gets an owned T. The test borrows it through catch_unwind, and after_each consumes it for cleanup.

How params are distinguished: Reference params (&T) come from before context. Owned params come from before_each context.

RSpec-style DSL

use spectacular::spec;

spec! {
    describe "database tests" {
        tokio;

        before -> PgPool {
            PgPool::connect("postgres://...").unwrap()
        }

        after |pool: &PgPool| {
            pool.close();
        }

        async before_each |pool: &PgPool| -> TestContext {
            TestContext::seed(pool).await
        }

        async after_each |pool: &PgPool, ctx: TestContext| {
            ctx.cleanup(pool).await;
        }

        async it "creates a team" |pool: &PgPool, ctx: TestContext| {
            // pool from before (shared &ref), ctx from before_each (owned)
        }
    }
}

Attribute style

use spectacular::{test_suite, before, after, before_each, after_each};

#[test_suite(tokio)]
mod database_tests {
    #[before]
    fn init() -> PgPool {
        PgPool::connect("postgres://...").unwrap()
    }

    #[after]
    fn cleanup(pool: &PgPool) {
        pool.close();
    }

    #[before_each]
    async fn setup(pool: &PgPool) -> TestContext {
        TestContext::seed(pool).await
    }

    #[after_each]
    async fn teardown(pool: &PgPool, ctx: TestContext) {
        ctx.cleanup(pool).await;
    }

    #[test]
    async fn test_create_team(pool: &PgPool, ctx: TestContext) {
        // pool from before (shared &ref), ctx from before_each (owned)
    }
}

Inferred context (no return type)

Hooks can omit their return type and let the macro infer everything from downstream consumers.

before — inferred from &T params:

When before has no -> Type but a downstream hook or test uses an explicit &T param, the macro infers OnceLock<T> automatically:

use spectacular::spec;

spec! {
    mod inferred_before {
        before { String::from("shared") }

        it "receives shared ref" |val: &String| {
            assert_eq!(val, "shared");
        }
    }
}

before_each — inferred from _ params:

When before_each has no return type, the last expression of the body is the context. Tests use _ as the param type and the compiler infers the rest:

use spectacular::spec;

spec! {
    mod inferred_context {
        before_each {
            (String::from("hello"), 42u32)
        }

        it "receives inferred values" |s: _, n: _| {
            assert_eq!(s, "hello");
            assert_eq!(n, 42);
        }
    }
}

Without _ params or &T consumers, hooks with no return type are fire-and-forget as usual.

Hook Execution Order

For each test in a group that opts into suite hooks:

suite::before            (Once -- first test in binary triggers it)
  group::before          (Once -- first test in group triggers it)
    suite::before_each
      group::before_each
        TEST
      group::after_each
    suite::after_each
  group::after           (countdown -- last test in group triggers it)

After-hooks are protected by catch_unwind, so cleanup runs even if a test panics.

Async Tests

Both spec! and #[test_suite] support async test cases and hooks. Specify a runtime (tokio or async_std) to enable async:

use spectacular::spec;

spec! {
    describe "my async tests" {
        tokio;  // or async_std;

        async before_each { db_connect().await; }

        async it "fetches data" {
            let result = fetch().await;
            assert!(result.is_ok());
        }

        it "sync test works too" {
            assert_eq!(1 + 1, 2);
        }
    }
}

Feature-based default: Enable the tokio or async-std feature on spectacular to auto-detect the runtime:

[dev-dependencies]
spectacular = { version = "0.1", features = ["tokio"] }

With the feature enabled, async it / async fn test cases Just Work without explicit tokio; or #[test_suite(tokio)].

Attribute Style Reference

Attribute Description
#[test_suite] Marks a module as a test group
#[test_suite(suite)] Same, with suite hook opt-in
#[test_suite(tokio)] Async test group with tokio runtime
#[test] Marks a function as a test
#[before] Once-per-group setup (max one per module)
#[after] Once-per-group teardown (max one per module)
#[before_each] Per-test setup (max one per module)
#[after_each] Per-test teardown (max one per module)

Context Injection Reference

spec! syntax

Form Description
describe "name" { } BDD-style group (string slugified to module name)
mod name { } Group with explicit module name
before -> Type { } Run-once setup returning shared context (explicit)
before { } Run-once setup with inferred context (when consumers use &T params)
after |name: &Type| { } Run-once teardown receiving shared context
before_each |name: &Type| -> Type { } Per-test setup with shared context input, owned output
before_each { } Per-test setup with inferred context (when tests use _ params)
after_each |name: &Type, name: Type| { } Per-test teardown with shared + owned context
it "desc" |name: &Type, name: Type| { } Test with shared + owned context

Attribute syntax

Pattern Description
fn init() -> T #[before] returning shared context (explicit)
fn init() #[before] with inferred context (when consumers use &T params)
fn cleanup(x: &T) #[after] receiving shared context
fn setup(x: &T) -> U #[before_each] with shared input, owned output
fn setup() #[before_each] with inferred context (when tests use _ params)
fn teardown(x: &T, y: U) #[after_each] with shared + owned
fn test_name(x: &T, y: U) #[test] with shared + owned

License

MIT -- see LICENSE for details.

About

An RSpec-inspired test framework for Rust with stackable before/after hooks.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages