rigtest 0.2.0

Runtime library for the cargo-rigtest test framework
Documentation

cargo-rigtest

CI rigtest on crates.io cargo-rigtest on crates.io docs.rs MSRV: 1.87 License: MIT OR Apache-2.0

A Cargo plugin for infrastructure and acceptance testing in Rust.

cargo-rigtest runs each test in its own subprocess, giving you process-level isolation, parallel execution, structured output, and first-class support for shared infrastructure setup — without the overhead of spinning up a full test harness.


Overview

Most Rust projects have two test layers covered: unit tests with #[test], and integration tests that compile and run a local binary. The third layer — acceptance tests against a deployed system — is almost always handled by reaching for another tool: pytest, Postman, shell scripts.

cargo-rigtest closes that gap. It is a Cargo plugin for running acceptance and end-to-end tests against real, deployed infrastructure — the kind of tests you run after a deploy to staging to confirm the system behaves as intended under real conditions, with real traffic and real service dependencies.

The motivating use case: a service is deployed to a staging environment. Before promoting to production, you want to verify that the signup flow completes, authenticated requests are accepted, and data persists correctly. These aren't unit tests — the binary is already compiled and running. They aren't local integration tests — there's nothing to mock. They're acceptance tests, and cargo-rigtest lets you write them in Rust.

Unlike a Python test harness bolted onto a Rust project, cargo-rigtest lives entirely inside your Cargo workspace. Test code can import your own types, reuse your HTTP client configuration, and be checked by the same compiler and CI pipeline as the rest of your project.


Features

cargo-rigtest is built around a few core ideas: tests that can't interfere with each other, infrastructure that's set up once and shared cleanly, and output that tells you exactly what failed without making you dig through logs.

  • Process isolation — each test runs in its own subprocess; a panic or crash cannot affect other tests
  • Parallel execution — tests run concurrently by default, configurable with --jobs
  • Global setup & teardown#[global_setup] and #[global_teardown] provision and clean up shared infrastructure once per suite
  • Per-test lifecycleTestContext provides scoped setup and teardown hooks for resources that belong to a single test
  • Serial, timeout, and retry — opt individual tests into exclusive execution, a hard time limit, or automatic retries
  • Captured output — held per test and printed only on failure, nextest-style
  • Runtime skiprigtest::skip!("reason") lets a test opt out gracefully at runtime

Installation

Getting started is two steps: install the cargo rigtest command, then add the rigtest library to your project.

Install the CLI

From crates.io (builds from source — requires a Rust toolchain):

cargo install cargo-rigtest

Pre-built binaries are available for macOS, Linux, and Windows on the releases page. macOS and Linux releases are .tar.gz archives — extract and place cargo-rigtest somewhere on your PATH. The Windows release is a plain .exe — download it, rename it if desired, and place it on your PATH.

macOS note: The release binaries are ad-hoc signed but not notarized or Developer ID signed. Gatekeeper may block the binary on first launch with a security warning. You can bypass this by right-clicking the binary in Finder and choosing Open, or by running xattr -d com.apple.quarantine /path/to/cargo-rigtest in your terminal. The Homebrew method below handles this automatically and is the recommended install path on macOS.

Homebrew (macOS and Linux):

brew tap anthonyoteri/tap
brew install cargo-rigtest

Add the library

[dev-dependencies]
rigtest = "0.1"

If your tests make HTTP calls, enable the http-client feature to get a pre-built reqwest::Client available as ctx.client in every test:

[dev-dependencies]
rigtest = { version = "0.1", features = ["http-client"] }

You can also make HTTP calls without this feature — just bring your own client library and construct it in your tests.


Quick start

1. Add a test target

In your Cargo.toml, add a [[test]] section with harness = false:

[[test]]
name = "acceptance"
path = "tests/acceptance.rs"
harness = false

2. Write the test file

use std::sync::Arc;
use rigtest::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct State {
    base_url: String,
}

#[global_setup]
async fn setup() -> State {
    State { base_url: "http://localhost:8080".to_string() }
}

#[global_teardown]
async fn teardown(state: State) {
    println!("releasing resources for {}", state.base_url);
}

#[testcase]
async fn homepage_returns_200(
    ctx: Arc<TestContext>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let state = ctx.global_data.downcast_ref::<State>().unwrap();
    // ctx.client requires the `http-client` feature
    let resp = ctx.client.get(&state.base_url).send().await?;
    assert_eq!(resp.status(), 200);
    Ok(())
}

fn main() {
    rigtest::run_main();
}

3. Run

cargo rigtest run

Test attributes

#[testcase]

Registers an async function as a test case. The function must have this signature:

async fn name(ctx: Arc<TestContext>) -> Result<(), Box<dyn std::error::Error + Send + Sync>>

Optional flags can be combined in any order:

#[testcase(serial, timeout = std::time::Duration::from_secs(30), retries = 2)]
Flag Description
serial Run this test exclusively — no other test runs concurrently with it
timeout = <Duration> Hard-kill the test subprocess and report failure if it exceeds the duration
retries = <N> Retry a failing test up to N additional times before reporting failure

Note on timeout and teardown: when a timeout fires, the subprocess is terminated — on Linux and macOS a graceful signal is sent first, with a short window for the process to exit cleanly before a hard kill follows; on Windows the process is hard-killed immediately. Either way, teardown registered with ctx.teardown(...) will not run. Resources that must be released regardless of outcome should be handled in #[global_teardown], which runs outside the test subprocess.

#[global_setup]

Runs once before any test in the suite. The return value is serialized and passed to each test subprocess. At most one may be defined.

#[global_setup]
async fn setup() -> MyState {
    MyState { db_url: std::env::var("DATABASE_URL").unwrap() }
}

The return type must implement serde::Serialize and serde::Deserialize — the state is serialized to cross the process boundary into each test subprocess. This means it can only hold serializable values: URLs, ports, credentials, identifiers. Live resources such as connection pools, file descriptors, and socket handles cannot survive the round-trip — store the configuration needed to recreate them instead.

#[global_teardown]

Runs once after all tests finish. Receives the value produced by #[global_setup]. At most one may be defined.

#[global_teardown]
async fn teardown(state: MyState) {
    MyDb::connect(&state.db_url).await.unwrap().drop_schema().await;
}

Per-test setup and teardown

TestContext provides setup and teardown hooks for resources with a clear per-test lifecycle. The global argument in both closures is the deserialized state from #[global_setup] — use it to create live resources within the test.

#[testcase]
async fn creates_a_record(
    ctx: Arc<TestContext>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let mut conn = ctx
        .setup(|global| async move {
            let state = global.downcast_ref::<State>().unwrap();
            db::connect(&state.db_url).await
        })
        .await?;  // failure reported as "setup failed: ..."

    conn.insert("hello").await?;
    assert_eq!(conn.count().await?, 1);

    ctx.teardown(|_global| async move {
        conn.rollback().await?;
        Ok(())
    })
    .await?;  // failure reported as "teardown failed: ..."

    Ok(())
}

Skipping tests

Use rigtest::skip! to skip a test at runtime with an optional reason:

#[testcase]
async fn requires_live_database(
    _ctx: Arc<TestContext>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    if std::env::var("DATABASE_URL").is_err() {
        rigtest::skip!("DATABASE_URL not set");
    }
    // ...
    Ok(())
}

Skipped tests appear in the summary as SKIP and do not count as failures.


Running tests

cargo rigtest run [OPTIONS]
Flag Description
--jobs <N> Maximum parallel test jobs (default: number of CPUs)
--seed <N> Fix the random order seed for reproducible runs
--filter <STRING> Only run tests whose name contains STRING
--test <NAME> Only run the named test target (repeatable: --test a --test b)
--package <NAME> Package containing the test targets
--no-capture Print test output in real time instead of capturing it (implies --jobs 1)

The seed is printed at the start of every run so a failing order can be reproduced exactly:

cargo rigtest run --seed 12345678

Output

cargo-rigtest produces nextest-style output. In a TTY, running tests show live spinners; results are printed as they complete:

── global setup
PASS [0.142s] homepage_returns_200
SKIP [0.031s] requires_live_database: DATABASE_URL not set
FAIL [0.089s] creates_a_record: assertion failed at tests/acceptance.rs:42

  ── stdout
  created record with id 99
  expected count 1, got 2

────────────────────────────────────────────────────────────
     Summary [0.21s] 3 tests run: 1 passed, 1 skipped, 1 failed
── global teardown

In CI or piped output, spinners are replaced with plain lines so no output is lost.


Multiple test targets

If a package has more than one rigtest test target, all of them are discovered and run in sequence automatically:

cargo rigtest run                          # run all rigtest targets
cargo rigtest run --test smoke             # run one
cargo rigtest run --test smoke --test e2e  # run two

cargo-rigtest identifies rigtest test targets automatically and ignores any other harness = false binaries in the package.


Crate layout

Crate Description
cargo-rigtest The cargo rigtest CLI plugin
rigtest Runtime library — add this to [dev-dependencies]

License

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