Skip to content

emilpriver/suitecase

Repository files navigation

Suitcase

The structured test toolkit. A lightweight sync Rust library for named cases, optional setup / teardown at suite scope and before_each / after_each around each case. Build case lists with cases!; use test_suite! to emit a single #[test] that runs all cases sequentially with formatted output.

Heavy development: The API is still evolving. Expect breaking changes between releases until a stable 1.0; pin an exact version (or git revision) in Cargo.toml if you need upgrades to be predictable.

Install · Usage · CLI · Case Selection · Dependencies · Fail · Assertions · Mocking · Examples · AI-assisted changes · Docs (after publish; cargo doc --open locally)


  • Sync runnerrun orchestrates hooks and case bodies; integrate async I/O with something like tokio::runtime::Handle::block_on in hooks or cases.
  • Sequential executiontest_suite! emits one #[test] that runs all cases in slice order. Cases that depend on earlier state (setup → mutate → assert) always execute correctly.
  • Formatted output — Each case prints ▶ name, then ✓ name (Xms) or ✗ name (Xms). All cases run even if one fails; the first panic is re-raised after completion.
  • Hooks as optional fnsHookFns holds Option<fn(&mut S)> per lifecycle slot; use [None] to skip.
  • Case selectionRunConfig::filter, RunConfig::filters, and RunConfig::pattern let you run a single case, a group, or a glob match. Dependencies declared via depends_on are auto-included.
  • CLIsuitecase test runs suitecase test and renders a formatted summary with pass/fail counts, per-case timing, and failure details.
  • Assertionssuitecase::assert provides testify/assert-style helpers (equal, contains, assert_ok, …) that panic on failure with clear messages (#[track_caller] where applicable).
  • Mockingsuitecase::mock provides testify/mock-style expectations and call recording (Mock, mock_args!). Use suitecase::mock_args at the crate root (same macro).

Install

Add suitecase to your Cargo.toml (no required dependencies beyond std):

[dependencies]
suitecase = "0.1"

Install the CLI:

cargo install suitecase

CLI

Run tests with formatted suitecase output:

suitecase test --lib
suitecase test --example quickstart
suitecase test --example sqlx_sqlite
suitecase test --lib -- shared_state    # filter by test name

Filtering individual cases

Use --case to run a single case or a group of cases within a suite. Supports exact match, glob (*), and full regex:

suitecase test --lib --case "test_inc"           # exact match
suitecase test --lib --case "test_*"             # glob pattern
suitecase test --lib --case "^test_.*_verify$"   # regex

To enable CLI case filtering, use RunConfig::from_args() in your test code:

#[test]
fn my_suite() {
    let mut suite = MySuite::default();
    run(&mut suite, CASES, RunConfig::from_args(), &HookFns::default());
}

If --case is not provided, RunConfig::from_args() falls back to running all cases.

Output:

Suitecase Summary
────────────────────────────────────────────────
  ✓ test_inc (0ms)
  ✓ test_inc_verify (0ms)
  ✓ test_double (0ms)
  ...
────────────────────────────────────────────────
  PASSED  12 passed, 0 failed  (total: 0ms)

On failure, a FAILURES section shows the panicked case name, duration, file location, and assertion message.


Usage

Quickstart

Put this in tests/suite.rs or #[cfg(test)] mod tests { ... }. The example is suitecase test --example quickstartone test line that runs every case in order on one suite.

use suitecase::{cases, run, Case, HookFns, RunConfig};

#[derive(Default)]
struct Counter {
    n: i32,
}

impl Counter {
    fn test_inc(&mut self) {
        self.n += 1;
    }
    fn test_inc_verify(&mut self) {
        assert_eq!(self.n, 1);
    }
}

static MY_CASES: &[Case<Counter>] = cases![Counter, s =>
    test_inc => { s.test_inc(); },
    test_inc_verify => { s.test_inc_verify(); },
];

static MY_HOOKS: HookFns<Counter> = HookFns {
    setup_suite: None,
    teardown_suite: None,
    before_each: None,
    after_each: None,
};

#[test]
fn quickstart() {
    let mut suite = Counter::default();
    run(&mut suite, MY_CASES, RunConfig::all(), &MY_HOOKS);
}

Run: suitecase test.

Hooks

Pass HookFns as the last argument to run. Each field is Option<fn(&mut S)> — [Some(...)] or [None] / [HookFns::default()].

Case selection

Run a single case, a group, or a glob pattern by passing a RunConfig to run. Hooks (setup_suite, teardown_suite, before_each, after_each) still execute for selected cases.

use suitecase::{cases, run, Case, HookFns, RunConfig};

#[derive(Default)]
struct Counter { n: i32 }

static CASES: &[Case<Counter>] = cases![Counter, s =>
    test_a => { s.n = 1; },
    test_b => { s.n = 2; },
    test_c => { s.n = 3; },
];

// Single case
run(&mut Counter::default(), CASES, RunConfig::filter("test_b"), &HookFns::default());

// Multiple cases
run(&mut Counter::default(), CASES, RunConfig::filters(["test_a".to_string(), "test_c".to_string()]), &HookFns::default());

// Glob pattern (* matches any characters)
run(&mut Counter::default(), CASES, RunConfig::pattern("test_*"), &HookFns::default());

Case dependencies (depends_on)

Declare that a case requires other cases to run first. When you filter to a case with dependencies, those dependencies are automatically included and run in topological order.

use suitecase::{cases, run, Case, HookFns, RunConfig};

#[derive(Default)]
struct Db { connected: bool, migrated: bool }

static CASES: &[Case<Db>] = cases![Db, s =>
    connect => { s.connected = true; },
    migrate(depends_on = [connect]) => { s.migrated = true; },
    query(depends_on = [migrate]) => { assert!(s.connected && s.migrated); },
];

// Running only "query" auto-includes connect → migrate → query
let mut db = Db::default();
run(&mut db, CASES, RunConfig::filter("query"), &HookFns::default());

Circular dependencies and missing dependencies panic at runtime.

Fail and fail_now

Use suitecase::fail and suitecase::fail_now inside case bodies:

  • fail(msg) — marks the current case as failed, continues running remaining cases, exits cleanly (no re-panic).
  • fail_now(msg) — marks the current case as failed, aborts all remaining cases, runs teardown_suite, then panics.

If a case fails via either function, cases that depend on it (via depends_on) are skipped (shown as ).

use suitecase::{cases, run, Case, HookFns, RunConfig, fail};

#[derive(Default)]
struct App { ready: bool }

static CASES: &[Case<App>] = cases![App, s =>
    setup => { s.ready = true; },
    check => { if !s.ready { fail("not ready"); } },
];

Sequential test suite (test_suite!)

test_suite! emits a single #[test] that runs all cases sequentially in slice order. Each case prints ▶ name before execution and ✓ name (Xms) or ✗ name (Xms) after. All cases run even if one fails — the first panic is re-raised after all cases complete.

use suitecase::{test_suite, HookFns};

#[derive(Default)]
struct Counter { n: i32 }

test_suite!(
    Counter,
    MY_SHARED_SUITE,
    quickstart_test_run,
    Counter::default(),
    HookFns::default(),
    s =>
    test_inc => { s.n += 1; },
    test_inc_verify => { assert_eq!(s.n, 1); },
);

Run: suitecase test or suitecase test.

Assertions (assert)

Import helpers from suitecase::assert inside #[test] functions, suite case bodies, or anywhere you want a named check that fails by panicking with a readable message.

use suitecase::assert::{assert_ok, contains_str, equal};

let n = assert_ok(Ok::<_, &str>(42));
equal(&n, &42);
contains_str("hello world", "world");

See the module docs for the full flat list (collections, errors, floats, options/results, ordering, panics, pointers, time, …).

Mocking (mock)

suitecase::mock is for test doubles: register expectations on a Mock, forward calls from your stub with method_called and mock_args!, then verify with assert_expectations and a small TestingT implementation.

use suitecase::mock::{eq, Mock, TestingT};

struct NoopT;
impl TestingT for NoopT {
    fn errorf(&self, _: &str) {}
    fn fail_now(&self) {}
}

let m = Mock::new();
m.on("greet", vec![eq("Ada".to_string())])
    .returning(|| vec![Box::new("Hello, Ada".to_string())])
    .finish();

let out = m.method_called("greet", suitecase::mock_args!("Ada".to_string()));
assert_eq!(out.string(0), "Hello, Ada");
assert!(m.assert_expectations(&NoopT));

Unexpected calls panic from method_called (see rustdoc). Matchers include eq, anything, anything_of_type, matched_by.


Examples

Example Run
examples/quickstart.rs suitecase test --example quickstart · suitecase test --example quickstart
examples/mock.rs cargo run --example mock · suitecase test --example mock (mocked HTTP-style JSON)
examples/sqlx_sqlite.rs suitecase test --example sqlx_sqlite · suitecase test --example sqlx_sqlite
Every example at once suitecase test --examples
Integration tests (tests/suite.rs) suitecase test

The sqlx example uses sqlx + tokio, embedded migrations in setup_suite, and cases! blocks that call helper functions, plus test_suite!.

[dependencies]
suitecase = "0.1"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

More documentation

  • AI_USAGE.md — using LLMs to draft changes is allowed; you must understand the patch, justify it in review, and avoid unnecessary complexity (see file).
  • cargo doc --opencases! and test_suite! include declaration / description / examples.
  • Crate root and suite describe how run selects cases and orders hooks.
  • assert — panicking assertion helpers (submodules per domain; flat re-exports at the module root).
  • mockMock, matchers, Arguments, mock_args!.

About

The structured test toolkit for rust

Resources

License

Stars

Watchers

Forks

Contributors

Languages