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:
[]
= { = "skuld" }
[[]]
= "my_tests"
= "tests/my_tests.rs"
= false
Create the test entry point:
// tests/my_tests.rs
Unit tests
To use skuld for tests inside src/, disable the default harness for the library target and add the entry point:
[]
= false
// lib.rs
Now #[skuld::test] works in any #[cfg(test)] module under src/:
// src/my_module.rs
Note: Without
[lib] harness = false, the default Rust test harness runs instead of skuld, silently reportingrunning 0 testswith no error.
Writing tests
Annotate test functions with #[skuld::test]. The attribute supports several options:
// Standard outer attributes also work (must appear after #[skuld::test]):
// Standard outer attribute form (must appear after #[skuld::test]):
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:
[]
= { = "skuld", = ["tokio"] }
async
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:
async
Fixtures
Fixtures provide dependency-injected values to test functions. Define a fixture with #[skuld::fixture] and inject it with #[fixture] on a test parameter:
use Path;
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). |
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:
// TempDir implements Deref<Target = Path>, so both work:
Labels
Labels are sentinel values for tagging and filtering tests. Define them with #[skuld::label]:
pub const DOCKER: Label;
pub const SLOW: Label;
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):
SKULD_LABELS=docker SKULD_LABELS="docker | slow" SKULD_LABELS="(docker | integration) & !slow"
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
pub const SMOKE: Label;
pub const UNIT: Label;
pub const SLOW: Label;
default_labels!;
// inherits [SMOKE, UNIT]
// gets [SLOW], NOT [SMOKE, UNIT, SLOW]
// gets nothing (explicit opt-out)
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:
Fixtures can also declare serial. Any test using a serial fixture automatically inherits the flag:
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:
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.
SKULD_DEBUG=1
Set SKULD_DEBUG=1 to get diagnostic lines around each test's execution, useful for debugging capture setup or runner behavior:
SKULD_DEBUG=1
# ...
# [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
smoke_test: valgrind not installed
full_pipeline: valgrind not installed
How it works
#[skuld::test]is a proc macro that preserves the original function and appends aninventory::submit!call to register it with the harness.run_all()(orTestRunner::run_tests()) iterates all registered tests, checks preconditions and fixture requirements at runtime, and buildslibtest-mimic::Trials — marking unmet tests as ignored.- 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.
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.