https://github.com/bindreams/skuld
https://github.com/bindreams/skuld
Last synced: 8 days ago
JSON representation
- Host: GitHub
- URL: https://github.com/bindreams/skuld
- Owner: bindreams
- License: apache-2.0
- Created: 2026-03-04T12:25:11.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-11T00:39:33.000Z (16 days ago)
- Last Synced: 2026-04-11T02:09:23.761Z (16 days ago)
- Language: Rust
- Size: 160 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE.md
Awesome Lists containing this project
README
# skuld
Test harness for Rust with runtime preconditions, fixture injection, and label filtering.
Rust's built-in test framework has no way to mark a test as "ignored with reason" at runtime. Tests that need external tools (valgrind, docker, a built binary) either silently pass when the tool is missing, or hard-fail. `skuld` replaces the built-in harness with one that checks preconditions at runtime, reports unmet ones as `ignored`, and prints a summary showing exactly what's missing.
## Setup
Add a `[[test]]` target with `harness = false` in your `Cargo.toml`:
```toml
[dev-dependencies]
skuld = { path = "skuld" }
[[test]]
name = "my_tests"
path = "tests/my_tests.rs"
harness = false
```
Create the test entry point:
```rust
// tests/my_tests.rs
#[path = "my_tests_support/mod.rs"]
mod support;
fn main() {
skuld::run_all();
}
```
## Unit tests
To use `skuld` for tests inside `src/`, disable the default harness for the library target and add the entry point:
```toml
[lib]
harness = false
```
```rust
// lib.rs
#[cfg(test)]
fn main() {
skuld::run_all();
}
```
Now `#[skuld::test]` works in any `#[cfg(test)]` module under `src/`:
```rust
// src/my_module.rs
#[cfg(test)]
mod tests {
#[skuld::test]
fn unit_test_example() {
assert_eq!(2 + 2, 4);
}
}
```
> **Note:** Without `[lib] harness = false`, the default Rust test harness runs instead of skuld, silently reporting `running 0 tests` with no error.
## Writing tests
Annotate test functions with `#[skuld::test]`. The attribute supports several options:
```rust
fn valgrind() -> Result<(), String> {
use std::process::{Command, Stdio};
Command::new("valgrind")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
.then_some(())
.ok_or_else(|| "valgrind not installed".into())
}
#[skuld::test(requires = [valgrind], labels = [SLOW])]
fn smoke_test() {
// Runs only if valgrind is available.
}
#[skuld::test(name = "custom display name")]
fn internal_name() { /* ... */ }
#[skuld::test(ignore)]
fn wip() { /* ... */ }
#[skuld::test(ignore = "blocked on #123")]
fn blocked_test() { /* ... */ }
// Standard outer attributes also work (must appear after #[skuld::test]):
#[skuld::test]
#[ignore]
fn wip_outer() { /* ... */ }
#[skuld::test]
#[ignore = "blocked on #456"]
fn blocked_outer() { /* ... */ }
#[skuld::test(serial)]
fn modifies_global_state() { /* ... */ }
#[skuld::test(should_panic)]
fn panics_on_bad_input() {
my_function(invalid_input);
}
#[skuld::test(should_panic = "out of range")]
fn panics_with_message() {
my_function(too_large);
}
// Standard outer attribute form (must appear after #[skuld::test]):
#[skuld::test]
#[should_panic(expected = "out of range")]
fn panics_outer() {
my_function(too_large);
}
```
Every `#[skuld::test]` function is registered with the harness. Functions without `#[skuld::test]` are invisible to skuld.
## Async tests
Enable the `tokio` feature to use `async fn` test bodies:
```toml
[dev-dependencies]
skuld = { path = "skuld", features = ["tokio"] }
```
```rust
#[skuld::test]
async fn connects_to_server() {
let stream = tokio::net::TcpStream::connect("127.0.0.1:8080").await.unwrap();
// ...
}
```
Async tests run on a single-threaded tokio runtime (`current_thread` with `enable_all()`). All existing features — fixtures, `requires`, `should_panic`, `serial`, labels — work with async tests.
Tests may also return `Result<(), E>` where `E: Debug`. An `Err` return fails the test:
```rust
#[skuld::test]
async fn parses_config() -> Result<(), Box> {
let config = load_config().await?;
assert_eq!(config.port, 8080);
Ok(())
}
```
## Fixtures
Fixtures provide dependency-injected values to test functions. Define a fixture with `#[skuld::fixture]` and inject it with `#[fixture]` on a test parameter:
```rust
use std::path::Path;
#[skuld::fixture(deref)]
fn temp_dir(#[fixture(test_name)] name: &str) -> Result {
// skuld provides TempDir and TestName as built-in fixtures.
// This example shows how custom fixtures work.
todo!()
}
#[skuld::test]
fn my_test(#[fixture(temp_dir)] dir: &Path) {
assert!(dir.exists());
}
```
### Scopes
Each fixture has a lifetime scope:
| Scope | Behaviour |
| -------------------- | ------------------------------------------------------------------- |
| `variable` (default) | Fresh instance per request. Dropped when the `FixtureHandle` drops. |
| `test` | Cached per test. Dropped when the test ends. |
| `process` | Cached globally. Dropped after all tests finish (LIFO). |
```rust
#[skuld::fixture(scope = process, requires = [docker_available])]
fn corpus_image() -> Result { /* ... */ }
```
A fixture may only depend on fixtures of the **same or wider** scope. Dependency cycles are detected at startup.
### Built-in fixtures
| Fixture | Scope | Type | Serial | Description |
| ----------- | -------- | ---------------------------- | ------ | ---------------------------------------------- |
| `test_name` | test | `TestName` (deref to `&str`) | no | Current test function name |
| `temp_dir` | variable | `TempDir` (deref to `&Path`) | no | Temporary directory named after the test |
| `env` | test | `EnvGuard` | yes | Set/remove env vars with automatic revert |
| `cwd` | test | `CwdGuard` | yes | Change working directory with automatic revert |
### Deref coercion
Fixtures annotated with `deref` can be injected as their `Deref::Target` type:
```rust
// TempDir implements Deref, so both work:
fn example1(#[fixture(temp_dir)] dir: &skuld::TempDir) { /* ... */ }
fn example2(#[fixture(temp_dir)] dir: &Path) { /* ... */ }
```
## Labels
Labels are sentinel values for tagging and filtering tests. Define them with `#[skuld::label]`:
```rust
#[skuld::label] pub const DOCKER: skuld::Label;
#[skuld::label] pub const SLOW: skuld::Label;
#[skuld::test(labels = [DOCKER, SLOW])]
fn heavy_test() { /* ... */ }
```
The label's string name is the identifier lowercased (`DOCKER` → `"docker"`). To reuse a label from another crate, just `use` it: `use other_crate::DOCKER;`.
Filter with the `SKULD_LABELS` environment variable using boolean expressions (`&` AND, `|` OR, `!` NOT, parentheses, plus the `true` and `false` literals):
```bash
SKULD_LABELS=docker cargo test # only "docker"
SKULD_LABELS="docker | slow" cargo test # "docker" OR "slow"
SKULD_LABELS="(docker | integration) & !slow" cargo test # combined
```
Unset `SKULD_LABELS` runs all tests. Precedence: `!` > `&` > `|`. Label names are matched case-insensitively, so `SKULD_LABELS=DOCKER` is equivalent to `SKULD_LABELS=docker`. Filters are stored canonically, so `parse("a & b") == parse("b & a")`.
### Module-level defaults
```rust
#[skuld::label] pub const SMOKE: skuld::Label;
#[skuld::label] pub const UNIT: skuld::Label;
#[skuld::label] pub const SLOW: skuld::Label;
skuld::default_labels!(SMOKE, UNIT);
#[skuld::test] // inherits [SMOKE, UNIT]
fn test_a() { /* ... */ }
#[skuld::test(labels = [SLOW])] // gets [SLOW], NOT [SMOKE, UNIT, SLOW]
fn test_b() { /* ... */ }
#[skuld::test(labels = [])] // gets nothing (explicit opt-out)
fn test_c() { /* ... */ }
```
## Serial tests
Tests that modify process-global state (environment variables, current directory) must not run in parallel with other such tests. Mark them with `serial`:
```rust
#[skuld::test(serial)]
fn test_with_global_state() { /* ... */ }
```
Fixtures can also declare `serial`. Any test using a serial fixture automatically inherits the flag:
```rust
#[skuld::fixture(scope = test, serial)]
fn env() -> Result { /* ... */ }
#[skuld::test]
fn my_test(#[fixture] env: &EnvGuard) {
// Automatically serial — env fixture declares it.
env.set("MY_VAR", "value");
}
```
All serial tests run under a cross-process file lock (`target/{profile}/.skuld-serial.lock`). Under `cargo test` the lock is trivially uncontended; under `cargo nextest run` (process-per-test) it serializes across processes automatically. Non-serial tests are unaffected and may still run in parallel.
## Dynamic tests
Use `TestRunner` to mix inventory-registered and runtime-generated tests:
```rust
fn main() {
#[skuld::label] const DATA: skuld::Label;
let mut runner = skuld::TestRunner::new();
for file in std::fs::read_dir("test_data").unwrap() {
let path = file.unwrap().path();
runner.add(
path.display().to_string(),
&[DATA],
false,
move || { /* test body */ },
);
}
runner.run();
}
```
## Running tests
### Capture model
Under `cargo test`, skuld captures each test's `stdout` and `stderr` via a file-descriptor redirect (`dup2` on Unix; `SetStdHandle` + `_dup2` on Windows). On pass the captured bytes are discarded; on failure they are dumped to the real `stderr` between `---- captured ----` markers, followed by the panic. The capture intercepts at the FD level, so every write — `println!`, `eprintln!`, raw `io::stdout().write_all`, FFI output, tracing subscribers installed by the test body, and even output from spawned child processes — is captured. Tests are free to install their own `tracing_subscriber::registry().try_init()` and skuld stays out of the dispatch path entirely.
Because FD redirect is a process-wide operation, capture mode forces `--test-threads=1`. For parallel execution, either run with `--nocapture` or use `cargo nextest run` (recommended for large suites — nextest runs each test in its own subprocess and captures via OS pipes externally, so skuld's in-process redirect is unnecessary and disabled automatically). Serial tests are safe under nextest: the `serial` lock uses a cross-process file lock, so `#[skuld::test(serial)]` correctly serializes even when nextest spawns separate processes.
```bash
cargo test # default: FD capture, serial, silent on pass
cargo test -- --nocapture # no capture, default parallelism, all output visible
cargo nextest run # process-per-test parallelism via nextest
```
### `SKULD_DEBUG=1`
Set `SKULD_DEBUG=1` to get diagnostic lines around each test's execution, useful for debugging capture setup or runner behavior:
```bash
SKULD_DEBUG=1 cargo test
# ...
# [skuld] my_test: starting
# [skuld-debug] my_test: entering test scope
# [skuld-debug] my_test: capture enabled (fd redirect)
# [skuld-debug] my_test: capture disabled
# [skuld] my_test: pass (3 ms)
```
### A note on `tracing-subscriber`'s `tracing-log` feature
If your test code or code under test pulls in `tracing-subscriber` directly, **do not enable its `tracing-log` feature**. The feature auto-installs a `log::Log` shim on the first subscriber `init`, which mutates `log::max_level` globally. Downstream projects have hit Windows CI timeout regressions from this — see bindreams/hole#147. If you need the `log`→`tracing` bridge, call `tracing_log::LogTracer::init()` yourself in the test that needs it, and accept that doing so is a process-wide, one-time operation.
## Output
When all requirements are met:
```
running 2 tests
test smoke_test ... ok
test full_pipeline ... ok
test result: ok. 2 passed; 0 failed; 0 ignored
```
When a requirement is missing:
```
running 2 tests
test smoke_test ... ignored
test full_pipeline ... ignored
test result: ok. 0 passed; 0 failed; 2 ignored
--- Unavailable (2) ---
smoke_test: valgrind not installed
full_pipeline: valgrind not installed
```
## How it works
1. `#[skuld::test]` is a proc macro that preserves the original function and appends an `inventory::submit!` call to register it with the harness.
1. `run_all()` (or `TestRunner::run_tests()`) iterates all registered tests, checks preconditions and fixture requirements at runtime, and builds `libtest-mimic::Trial`s — marking unmet tests as ignored.
1. After `libtest-mimic::run()` completes, the unavailability summary is printed to stderr.
## License

Copyright 2026, Anna Zhukova
This project is licensed under the Apache 2.0 license. The license text can be found at [LICENSE.md](/LICENSE.md).
## About

**Skuld** is the youngest of the three Norns in Norse mythology — the weavers of fate who sit beneath the world-tree Yggdrasil. While her sisters Urðr and Verðanði govern the past and the present, Skuld presides over _what shall be_: obligations yet unfulfilled, debts yet unpaid. Her name shares its root with the English word _should_.