rigtest 0.3.1

Runtime library for the cargo-rigtest test framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# cargo-rigtest

[![CI](https://github.com/anthonyoteri/cargo-rigtest/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/anthonyoteri/cargo-rigtest/actions/workflows/ci.yml)
[![rigtest on crates.io](https://img.shields.io/crates/v/rigtest.svg?label=rigtest)](https://crates.io/crates/rigtest)
[![cargo-rigtest on crates.io](https://img.shields.io/crates/v/cargo-rigtest.svg?label=cargo-rigtest)](https://crates.io/crates/cargo-rigtest)
[![docs.rs](https://img.shields.io/docsrs/rigtest?label=docs.rs)](https://docs.rs/rigtest)
[![MSRV: 1.87](https://img.shields.io/badge/rustc-1.87+-orange.svg)](https://blog.rust-lang.org/2025/05/15/Rust-1.87.0.html)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](https://github.com/anthonyoteri/cargo-rigtest#license)

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 lifecycle**`TestContext` 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 skip**`rigtest::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](https://github.com/anthonyoteri/cargo-rigtest/releases).
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

```toml
[dev-dependencies]
rigtest = "0.1"
```

If your tests make HTTP calls, enable the `http-client` feature to get a
shared `reqwest::Client` via `ctx.client().await?` in every test:

```toml
[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`:

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

### 2. Write the test file

```rust
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::<State>();
    // ctx.client() requires the `http-client` feature
    let resp = ctx.client().await?.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:

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

Optional flags can be combined in any order:

```rust
#[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.

```rust
#[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.

```rust
#[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.

```rust
#[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:

```rust
#[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.

---

## HTTP client

Enable the `http-client` feature for a built-in `reqwest::Client` accessible
via `ctx.client().await?`. See [`examples/http-client`](examples/http-client)
for a working example including custom TLS configuration.

---

## SSH client

> **Unix only.** The `ssh-client` feature depends on [`openssh`]https://crates.io/crates/openssh,
> which delegates to the system `ssh` binary via Unix pipes. It does not
> compile on Windows or other non-Unix targets.

Enable the `ssh-client` feature for cached SSH sessions accessible via
`ctx.ssh(destination).await?`:

```toml
[dev-dependencies]
rigtest = { version = "0.1", features = ["ssh-client"] }
```

`ctx.ssh("user@host")` returns an `Arc<openssh::Session>` connected to the
given destination. Sessions are cached by destination string within the test
subprocess — repeated calls to the same host reuse the existing connection,
avoiding expensive reconnects over high-latency tunnels.

The `rigtest::ssh!` convenience macro runs a shell command in one line:

```rust
let output = rigtest::ssh!(ctx, "deploy@staging.example.com", "systemctl status app").output().await?;
assert!(output.status.success());
```

An optional destination-aware configurator can be registered to customise
the connection — for example to accept self-signed host keys in a CI
environment or to select a non-default identity file:

```rust
fn configure_ssh(
    _destination: &str,
    mut builder: rigtest::openssh::SessionBuilder,
) -> Result<rigtest::openssh::SessionBuilder, rigtest::Error> {
    builder.known_hosts_check(rigtest::openssh::KnownHosts::Accept);
    Ok(builder)
}

#[rigtest::main(ssh_client = configure_ssh)]
fn main() {}
```

The configurator receives the destination string so different hosts can
receive different configuration. Omitting the configurator uses the
`openssh` defaults, which inherit your SSH agent and `~/.ssh/config`
automatically.

See [`examples/ssh-client`](examples/ssh-client) for a complete working
example. Set `SSH_HOST=user@yourhost` before running, or leave it unset
to default to `localhost`.

---

## 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](LICENSE-APACHE) or
[MIT license](LICENSE-MIT) at your option.