Expand description
An RSpec-inspired test framework for Rust with stackable before/after hooks.
Spectacular provides three layers of test 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 |
§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)Groups without suite; skip the suite layer entirely.
§Quick Start
use spectacular::spec;
spec! {
mod arithmetic {
it "adds two numbers" {
assert_eq!(2 + 2, 4);
}
it "multiplies two numbers" {
assert_eq!(3 * 7, 21);
}
}
}§Group Hooks
use spectacular::spec;
use std::sync::atomic::{AtomicBool, Ordering};
static READY: AtomicBool = AtomicBool::new(false);
spec! {
mod with_hooks {
use super::*;
before { READY.store(true, Ordering::SeqCst); }
it "runs after setup" {
assert!(READY.load(Ordering::SeqCst));
}
}
}§Suite Hooks (3-Layer)
Place suite! as a sibling of your test groups, then opt in with suite;
(in spec!) 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); }
}
spec! {
mod database_tests {
use super::*;
suite;
it "has database access" {
assert!(DB_READY.load(Ordering::SeqCst));
}
}
}§Attribute Style
For those who prefer standard Rust attribute syntax — just use #[test]:
use spectacular::{test_suite, before};
#[test_suite]
mod my_tests {
#[before]
fn setup() { }
#[test]
fn it_works() {
assert_eq!(1 + 1, 2);
}
}§Async Tests
Both spec! and #[test_suite] support async test cases and hooks.
Specify a runtime (tokio or async_std) to enable async:
spec! style:
use spectacular::spec;
spec! {
mod 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);
}
}
}Attribute style:
use spectacular::{test_suite, before_each};
#[test_suite(tokio)]
mod my_async_tests {
#[before_each]
async fn setup() { db_connect().await; }
#[test]
async fn it_works() {
let result = fetch().await;
assert!(result.is_ok());
}
}Async after_each hooks are panic-safe — they run even if the test body
panics, using an async-compatible catch_unwind wrapper.
Feature-based default: If you enable the tokio or async-std feature
on spectacular, async tests auto-detect the runtime so you can omit the
explicit tokio; / #[test_suite(tokio)] argument:
[dev-dependencies]
spectacular = { version = "0.1", features = ["tokio"] }With the feature enabled, async it / async fn test cases Just Work.
Explicit runtime arguments always take precedence over the feature default.
If both features are enabled simultaneously, you must specify explicitly
(the macro will emit a compile error).
§Context Injection
Hooks can produce context values that flow naturally to tests and teardown hooks,
eliminating the need for thread_local! + RefCell patterns.
§before → shared &T via OnceLock
When before returns a value, it’s stored in a OnceLock<T>. Tests, before_each,
after_each, and after all receive &T.
§before_each → owned T per test
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.
spec! style:
use spectacular::spec;
spec! {
mod my_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 my_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)
When before_each has no return type, the last expression of the body is
the context. Tests and after_each use _ as the param type to receive it.
The macro detects _-typed params and inlines the body automatically.
Without _ params, a void before_each is fire-and-forget as usual.
Modules§
- prelude
- Convenience re-export of all spectacular macros.
Macros§
- spec
- Defines a test group using RSpec-style DSL.
- suite
- Defines suite-level hooks that run across all opted-in test groups.
Attribute Macros§
- after
- Marks a function as a once-per-group teardown hook inside a
#[test_suite]module. - after_
each - Marks a function as a per-test teardown hook inside a
#[test_suite]module. - before
- Marks a function as a once-per-group setup hook inside a
#[test_suite]module. - before_
each - Marks a function as a per-test setup hook inside a
#[test_suite]module. - test_
suite - Marks a module as a test suite using standard Rust attribute syntax.