Getting started with test-r
test-r is a testing framework for Rust which is almost a drop-in replacement for built-in tests, but enables several advanced features such as dependency injection, dynamic test generation, custom tags, inline customization of the test execution and more.
By replicating the built-in test framework’s command line interface, test-r tests work seamlessly with IDEs like Visual Studio Code, IntelliJ IDEA, Zed, and others. test-r also implements many unstable features of the built-in test framework, such as customizable test output, reporting and ensuring execution time, shuffling test execution and running #[bench] benchmarks.
To start using test-r, add it to the dev-dependencies section of your Cargo.toml:
[dev-dependencies]
test-r = "2"
There are three additional steps to take when using test-r in place of the built-in tests:
- Disabling the built-in test harness for every build target where
test-rwill be used - Enabling the
test-rtest harness by including its main function in every build target - Import
test-r’s customtestattribute where#[test]is used
This is explained in details on the Defining tests page, but the example below demonstrates how to set up a simple crate to run tests with test-r.
Example
The following Cargo.toml file sets up a simple library crate with test-r:
[package]
name = "test-r-demo"
version = "0.1.0"
edition = "2024"
[lib]
harness = false # Disable the built-in test harness
[dev-dependencies]
test-r = "2"
And a simple src/lib.rs file defining a single public function and a test for it:
#![allow(unused)]
fn main() {
#[cfg(test)]
test_r::enable!(); // Enabling test-r's test harness (once per build target)
pub fn lib_function() -> u64 {
println!("lib_function called");
11
}
#[cfg(test)]
mod tests {
use test_r::test; // Replacing the built-in #[test] attribute
use super::*;
#[test]
fn test_lib_function() {
assert_eq!(lib_function(), 11);
}
}
}
Optional crate features
The test-r test framework with the default set of enabled features supports running both sync and async tests, using Tokio as the async runtime.
It is possible to turn off the async support by disabling the tokio feature:
[dev-dependencies]
test-r = { version = "2", default-features = false }
Real-world usage
This section lists known projects that use test-r:
- Golem Cloud uses
test-rfor all its unit and integration tests. (GitHub)
What is not supported?
The following features are not supported by test-r:
- Running doctests
- Output capturing cannot be used together with parallel execution AND dependency injection. Any two of these three features can be chosen, but not all three at the same time.
Acknowledgements
Most of test-r‘s features were inspired by working with test frameworks in other languages, especially the ZIO Test framework for Scala. The idea of replicating the built-in harness’ command line interface came from the libtest-mimic crate. For some features that replicate built-in functionality, parts of the original libtest source code have been reused.
Core features
This chapter covers the core features of test-r:
- Defining tests using the
#[test]attribute - Running tests with the usual command line options
- Customizing the test output
Defining tests
Enabling the test-r harness
Writing tests with test-r is very similar to writing tests with the built-in test framework, but there are a few differences.
Disabling the built-in test harness
First, for every build target where test-r is going to be used, the built-in test harness must be disabled.
This is done by putting harness = false in build target’s section in Cargo.toml:
[lib]
harness = false
[[bin]]
harness = false
[[test]]
name = "integ-test-1"
harness = false
[[test]]
name = "integ-test-2"
harness = false
# ...
Mixing test-r and the built-in test harness
It is recommended to turn off running tests completely in the rest of the targets. For example if the crate produces both a library and an executable, and all the tests are in the library part, then put test = false in the [[bin]] section:
[[bin]]
test = false
[lib]
harness = false
Without this, cargo test will run all the test harnesses including the one where the built-in harness is not disabled ([[bin]] in this case), which may fail on some unsupported command line arguments that the test-r harness accepts.
If the intention is to use both test-r and the built-in test harness in the same crate, that’s possible, but be careful with the command line arguments passed to cargo test as some of them may be only supported by the unstable version of the built-in test framework.
Enabling the test-r harness
For every target where the built-in harness was disabled (with harness = false), we need to install test-r’s test runner instead. In other words, if the compilation is in test mode, we have to define a main function that runs the test-r test runner.
This can be done by adding the following macro invocation at the root of the given build target:
#![allow(unused)]
fn main() {
#[cfg(test)]
test_r::enable!();
}
- For
[lib]targets, this should be insrc/lib.rs(or whatever crate root is specified) - For
[[bin]]targets, this should be in thesrc/main.rs,src/bin/*.rsfiles or the one explicitly set in the crate manifest, for each binary - For
[[test]]targets, this should be in thetests/*.rsfiles for each test
Writing tests
Writing tests is done exactly the same way as with the built-in test framework, but with using test-r’s #[test] attribute instead of the built-in one. We recommend importing the test attribute with use test_r::test; so the actual test definitions look identical to the built-in ones, but it is not mandatory.
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use test_r::test;
#[test]
fn test_lib_function() {
assert_eq!(lib_function(), 11);
}
}
}
Within the test function itself any assertion macros from the standard library or any of the third-party assertion crates can be used. (All panics are caught and reported as test failures.)
Writing async tests
The same #[test] attribute can be used for async tests as well. The test runner will automatically detect if the test function is async and run it accordingly.
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use test_r::test;
#[test]
async fn test_async_function() {
assert_eq!(async_lib_function().await, 11);
}
}
}
Support for async tests requires the tokio feature, which is enabled by default.
test-r runs async tests compared to how #[tokio::test] does. While tokio's test attribute spawns a new current-thread (by default) Tokio runtime for each test, test-r uses a single multi-threaded runtime to run all the tests. This is intentional, to allow shared dependencies that in some way depend on the runtime itself.
Tests returning Result
Tests in test-r can have a Result<_, _> return type. This makes it easier to chain multiple functions within the test that can return with an Err, no need to unwrap each. A test that returns a Result::Err will be marked as failed just like as if it had panicked.
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use test_r::test;
#[test]
fn test_lib_function() -> Result<(), Box<dyn std::error::Error>> {
let result = lib_function()?;
assert_eq!(result, 11);
Ok(())
}
}
}
Ignoring tests
The standard #[ignore] attribute can be used to mark a test as ignored.
#![allow(unused)]
fn main() {
#[test]
#[ignore]
fn ignored_test() {
assert!(false);
}
}
Ignored tests can be run with the --include-ignored or --ignored flags, as explained in the running tests page.
Testing for panics
The #[should_panic] attribute can be used to mark a test as expected to panic. The test will pass if it panics, and fail if it doesn’t.
#![allow(unused)]
fn main() {
#[test]
#[should_panic]
fn panicking_test() {
panic!("This test is expected to panic");
}
}
Optionally the expected argument can be used to only accept panics containing a specific message:
#![allow(unused)]
fn main() {
#[test]
#[should_panic(expected = "expected to panic")]
fn panicking_test() {
panic!("This test is expected to panic");
}
}
Running tests
test-r replicates the command line interface of the built-in test harness, so every integration (scripts, IDE support, etc) should work just like without using test-r.
This includes some of the unstable flags too, test-r let’s use them without the need to enable the unstable features in the compiler.
Cargo test parameters vs test-r parameters
The cargo test command takes some of its own options, a test name, and a list of arguments passed to the test harness itself:```
Usage: cargo test [OPTIONS] [TESTNAME] [-- [ARGS]...]
The paramters passed in OPTIONS select which test targets to build and run. See the official documentation for more details.
TESTNAME is an optional parameter which selects which tests to run in each selected test target. How exactly it is interpreted depends on other options passed in the ARGS part.
Choose what to run
Matching on test names
cargo test hello
executes all tests that have the hello substring in their fully qualified name (module path + function name).
Multiple filter strings can be passed after --, which will run all tests matching any of the filters:
cargo test -- hello world
This runs all tests whose name contains hello or world.
cargo test hello -- --exact
will only run the test that has the exact fully qualified name hello, which in this case means a function named hello in the root module.
There is a special syntax to match on tags, see the tags chapter for more details.
Ignored tests
Tests marked with the #[ignore] attribute are not run by default. To run them, use the --include-ignored flag.
It is also possible to run only the ignored tests with the --ignored flag.
Tests expecting panic
Tests using the #[should_panic] attribute are run by default, but can be skipped with the --exclude-should-panic flag.
Tests vs benchmarks
The framework supports not only tests (defined with #[test]), but also benchmarks (defined with #[bench]). By default, the test runner executes both. It is possible to only run tests or benches with the --test and --bench flags.
Skipping some tests
The --skip option can be used to skip tests whose names contain the given substring (just like if they were marked with #[ignore]). It can be used multiple times to skip multiple groups of tests. When --exact is also passed, --skip requires an exact name match instead of a substring match.
cargo test -- --skip slow
This skips all tests whose name contains slow. Tags can also be used with --skip:
cargo test -- --skip ':tag:expensive'
Parallelism
By default, the test runner uses as many threads as there are logical cores on the machine. This can be changed with the --test-threads flag.
cargo test -- --test-threads=1
Note that parallelism can be also controlled on the code level per test suite with the #[sequential] attribute. See the per-test configuration chapter for more details.
Shuffle
The test runner executes tests in definition order. To shuffle the order, use the --shuffle flag. To have a deterministic, but shuffled order, use the --shuffle-seed providing a numeric seed.
Listing tests
It is possible to just list all the available tests, without executing anything with the –list command:
cargo test -- --list
Test output
There are various options controlling the output of the test runner. See the test output chapter for more details.
Debugging
Output capturing is implemented by forking one or more child processes and attaching to their standard output and error channels. This means that attaching a debugger to the parent process will not work as expected. When using a debugger, always pass the --nocapture flag to the test runner to disable output capturing, which guarantees that all the tests are executed in the single root process.
Test output
The default setting of test-r is to use the pretty format and capture test outputs.
Output format
There are four supported output formats in test-r, which can be selected with the --format flag:
pretty(default) - human-readable output showing the progress and the final results in a verbose wayterse- human-readable output, only emitting a single character for each test during the test runjson- emits JSON messages during the test run, useful for integration with other tools like IDEsjunit- writes a JUnit XML test report, useful for generating browsable test reports
When using the pretty (default) mode, the --color flag can be used to control whether the output should use colors or not:
auto(default) - colors are used if the terminal supports themalways- always use colorsnever- do not use colors
Capturing the test output
When output capturing is enabled, lines written to either the standard output or standard error channels are not shown immediately as the test runs. Instead, they are only shown if the test fails. This allows nicer visual tracking of the test progress and results.
The following options control this behavior:
--nocapture- disables output capturing, showing the output of each test as it runs--show-output- shows the output of all tests after they finish, regardless of whether they passed or failed
Note that this global setting of output capturing can be overwritten on a per-test basis using the #[always_capture] and #[never_capture] attributes, as explained in the per-test configuration chapter.
Host-side output capture
Some test dependencies — most notably #[test_dep(scope = HostedRpc, …)] owners (see Dependency sharing strategies) — run in the parent test runner process, not in the worker subprocesses that own a given test. Anything those owners write to standard output or standard error (including from background threads they spawn, or from inside dispatch) used to be invisible per-test: it landed on the runner’s own stdout/stderr, which is either swallowed by cargo test or, worse, interleaved into the structured --format=json / junit / ctrf streams.
When output capturing is on (the default), test-r now also captures the parent’s own stdout/stderr in the background and attributes each line to the test(s) that were running when the line was produced — a best-effort, window-overlap based attribution. These records show up alongside the test’s own captured output, prefixed with [host] so the provenance is visible. In --format=pretty, the prefix is also rendered in a dimmed colour.
---- mycrate::my_module::tests::my_test stdout/err ----
my own println from inside the test
[host] HOST_DISPATCH_HIT
[host] HOST_BG_THREAD_TICK
This feature is automatic, supported on Unix and Windows, and disabled in --nocapture mode (where everything just goes to the terminal anyway). If a line cannot be attributed to any test (e.g. it was produced before the suite started or in a gap between tests) it is silently dropped.
When tests run in parallel and their windows overlap, a single host-side line may legitimately be attributed to several tests at once.
Host-side attribution happens once at suite end, so the host lines only appear in formatters that render per-test output after the suite finishes: pretty, junit and ctrf. The json formatter is a streaming format that emits a test event for each test as soon as it finishes — well before suite-end attribution — so its per-test stdout field does not include host-side lines. The terse formatter never reports per-test output regardless of --show-output, so host lines do not appear there either.
--nocapture flag is not present, the test runner will emit a warning and fall back to single threaded execution.
Measuring and ensuring execution time
By default test-r follows the built-in test harness behavior and does not report test execution times. This can be changed by passing the --report-time flag. The --ensure-time flag not only reports these per-test execution times, but fails the test run if they exceed a pre-configured value. Learn more about this in The Rust Unstable Book.
Note that test-r provides a nicer way to fail long running tests (but only if the tokio feature is enabled) using the #[timeout(ms)] attribute, as explained in the per-test configuration chapter.
Saving the output to a log file
The test output can be saved into a log file using the --logfile <path> flag. Because of the issue described in the Rust issue tracker, the test runner cannot directly use the provided path as other test harnesses would overwrite it. Instead, test-r interprets the provided path as a template, and appends a random UUID to its file name part for each generated log file. This allows saving multiple JUnit test reports, for example, into a single directory, where a test browser can pick them up from.
Advanced features
This chapter covers the advanced features of test-r, which are either not available at all using the built-in test harness,
or at least being unstable.
- Dependency injection allows sharing dependencies between tests.
- Tags allow grouping tests and running only a subset of them.
- Benches are used to measure the performance of functions.
- Per-test configuration allows customizing the test execution from the code, instead of using command line options.
- Flaky tests can be either retried, or executed multiple times to verify they aren’t flaky
- Dynamic test generation allows creating new tests from code
Dependency injection
Tests can share dependencies in test-r. This is especially useful for integration tests where setting up the integration environment is expensive.
Using shared dependencies
To use a shared dependency from a test, we simply need to add a reference parameter to the test function:
#![allow(unused)]
fn main() {
use test_r::test;
struct SharedDependency {
value: i32,
}
struct OtherDependency {
value: i32,
}
#[test]
fn test1(shared: &SharedDependency) {
assert_eq!(shared.value, 42);
}
#[test]
async fn test2(shared: &SharedDependency, other: &OtherDependency) {
assert_eq!(shared.value, other.value);
}
}
The name of the parameters does not matter - test dependencies are indexed by their type. If a test needs multiple instances of the same type, a newtype wrapper can be used to distinguish them.
Providing shared dependencies
Shared dependencies need to be provided for each test suite. A test suite in test-r is the enclosing module where the test functions are defined. It is possible to provide different values for the same dependency in different suites, but it is also possible to “import” provided dependencies from an outer suite. This flexibility allows for a wide range of uses cases, from defining singleton dependencies for a whole crate to detailed customization for specific tests.
Test dependencies are provided by constructor functions annotated with #[test_dep]. The constructor function can be sync or async (if the tokio feature is enabled):
#![allow(unused)]
fn main() {
use test_r::test_dep;
#[test_dep]
async fn shared_dependency() -> SharedDependency {
SharedDependency { value: 42 }
}
#[test_dep]
fn other_dependency() -> OtherDependency {
OtherDependency { value: 42 }
}
}
Whether the dependency was created by a sync or async function does not matter - they can be used in both sync and async tests.
Using dependencies provided for an outer test suite
As explained above, test dependencies must be provided in each test module. So if we want to use the same instances in an inner test suite, it has to be inherited:
#![allow(unused)]
fn main() {
mod inner {
use test_r::{inherit_test_dep, test};
use super::SharedDependency;
inherit_test_dep!(SharedDependency);
#[test]
fn test3(shared: &SharedDependency) {
assert_eq!(shared.value, 42);
}
}
}
Dependency graph
Test dependency constructors can depend on other dependencies just like tests are. This allows defining a complex dependency graph, where each shared dependency is created in the correct order, and only when needed, and they got dropped as soon as no other test needs them.
The following example defines a third dependency (based on the above examples) which requires the other two to get constructed:
#![allow(unused)]
fn main() {
struct ThirdDependency {
value: i32,
}
#[test_dep]
fn third_dependency(shared: &SharedDependency, other: &OtherDependency) -> ThirdDependency {
ThirdDependency { value: shared.value + other.value }
}
}
Dependency tagging
It is possible to have multiple dependency constructors of the same type, distinguished by a string tag. This is an alternative to using newtype wrappers, and it enables the dependency matrix feature explained in the next section.
To tag a dependency, use the #[test_dep] attribute with the following argument:
#![allow(unused)]
fn main() {
#[test_dep(tagged_as = "tag1")]
fn shared_dependency_tag1() -> SharedDependency {
SharedDependency { value: 1 }
}
#[test_dep(tagged_as = "tag2")]
fn shared_dependency_tag2() -> SharedDependency {
SharedDependency { value: 2 }
}
}
Tagged dependencies are not injected automatically for parameters of the same type, they need to have a matching tagged_as attribute:
#![allow(unused)]
fn main() {
#[test]
fn test4(#[tagged_as("tag1")] shared: &SharedDependency) {
assert_eq!(shared.value, 1);
}
}
Tagged dependencies can also be used as parameters of dependency constructors. This allows a #[test_dep] to depend on a specific tagged instance of another dependency:
#![allow(unused)]
fn main() {
struct DerivedDependency {
value: i32,
}
#[test_dep]
fn derived_dependency(#[tagged_as("tag1")] shared: &SharedDependency) -> DerivedDependency {
DerivedDependency { value: shared.value * 2 }
}
}
It is also possible to inherit tagged dependencies from an outer suite:
#![allow(unused)]
fn main() {
mod inner {
use test_r::{inherit_test_dep, test};
use super::SharedDependency;
inherit_test_dep!(#[tagged_as("tag1")] SharedDependency);
inherit_test_dep!(#[tagged_as("tag2")] SharedDependency);
}
}
Dependency matrix
test-r combines the above described dependency tagging feature with its generated tests feature to provide an easy way to test a matrix of configurations, represented by different values of test dependencies.
This can be used for example to test a table of different inputs, or to run tests with multiple implementations of the same interface.
The first step is to define a tagged test dependency for each value used in the matrix.
Take the previous section as an example where two different SharedDependency was created with tags tag1 and tag2.
The second step is to define a matrix dimension with the define_matrix_dimension! macro:
#![allow(unused)]
fn main() {
define_matrix_dimension!(shd: SharedDependency -> "tag1", "tag2");
}
In this example:
shdis the name of the dimension - there can be an arbitrary number of dimensions defined, and they can be used in any combination in test functionsSharedDependencyis the type of the dependency"tag1", "tag2"are the tags used in the dependency matrix for this dependency
The third step is to mark one or more parameters of a test function to match one of the defined dimensions:
#![allow(unused)]
fn main() {
#[test]
fn test5(#[dimension(shd)] shared: &SharedDependency) {
// ...
}
}
The library will generate two separate test functions (named test5::test5_tag1 and test5::test5_tag2) from this definition, and each will use a different instance of SharedDependency.
Dependency sharing strategies
By default, every #[test_dep] is created once per test binary run and shared by all tests in that suite. This is convenient, but it interacts poorly with output capturing: because the materialised value lives inside the parent test runner process, capturing forces a single-threaded fallback when at least one such dep exists.
test-r supports per-dependency sharing strategies that let individual deps opt into safer-to-parallelise lifetimes:
| Strategy | One instance per… | Constructor runs in… | Parallel under capture? |
|---|---|---|---|
Shared | suite | parent | No (default) |
PerWorker | worker process | each worker | Yes |
Cloneable | suite (parent) + per worker copy | parent | Yes |
Hosted | suite (parent owns) + per worker handle | parent | Yes |
HostedRpc | suite (parent owns) + per worker stub | parent | Yes |
The default remains Shared, so any existing #[test_dep] keeps working unchanged.
Hosted ships with a worker-view picker that selects what shape the
worker side sees: worker = descriptor (the default — a reconstructed
handle), worker = rpc(Trait) (a method-routing stub), or
worker = both(Trait) (both views on a single owner). The latter two are
the worker-view shorthand for what would otherwise be a separate HostedRpc
registration or a manually-coordinated pair of registrations; see the
Hosted and HostedRpc sections for details.
Choosing a strategy
- Use
Sharedwhen the dep owns process-local state that genuinely cannot be duplicated (single global resource) AND a small descriptor cannot represent it for workers. - Use
PerWorkerwhen re-running the constructor on each worker is cheap and tests are happy with their own private instance (temp dirs, caches, in-memory stores). - Use
Cloneablewhen the constructor is expensive (compilation, parsing a large schema, fetching once over the network) but the resulting value can be cheaply round-tripped through a byte buffer. The parent runs the constructor exactly once and ships the wire form to every worker, where each worker reconstructs a local copy. - Use
Hostedwhen the dep owns a long-lived singleton service (TCP listener, Docker container, env-based test environment, gRPC server) that must NOT be duplicated across worker processes, but workers need a small handle (an address, a port, a credentials bundle) to reach it. - Use
HostedRpc(or equivalently,Hostedwithworker = rpc(Trait)) when the dep is a singleton that exposes a small, in-process Rust API (e.g. “give me the next unique id”), and you do not want to set up a real network protocol just to share it with worker subprocesses. The runtime provides the IPC channel; you provide the owner type, a trait (or hand-written stub), and a method dispatcher. - Use
Hostedwithworker = both(Trait)when the same owner needs to serve both a bulk-data descriptor handle (typically a connection address used by a gRPC client) and a small RPC control surface (kill / flush / snapshot). One owner, two worker-side views, no duplication.
PerWorker
Annotate the constructor with scope = PerWorker:
#![allow(unused)]
fn main() {
use test_r::{test, test_dep};
pub struct WorkerScratchDir(pub tempfile::TempDir);
#[test_dep(scope = PerWorker)]
fn create_scratch_dir() -> WorkerScratchDir {
WorkerScratchDir(tempfile::tempdir().expect("scratch dir"))
}
#[test]
fn writes_a_file(scratch: &WorkerScratchDir) {
let path = scratch.0.path().join("hello.txt");
std::fs::write(&path, b"hi").unwrap();
assert!(path.exists());
}
}
When the runner spawns worker children for output capturing, each worker materialises WorkerScratchDir independently. Tests scheduled on the same worker share the same instance; tests scheduled on different workers see independent instances.
Observing the worker index
PerWorker constructors (and the tests they feed) can read the zero-based index the parent assigned to the current worker via test_r::worker_index(). Use it to partition a global namespace so that workers cannot collide without coordination.
#![allow(unused)]
fn main() {
use std::sync::atomic::AtomicU16;
use test_r::test_dep;
pub struct LastUniqueId {
pub id: AtomicU16,
}
#[test_dep(scope = PerWorker)]
fn last_unique_id() -> LastUniqueId {
// Reserve the high 8 bits for the worker index, leaving 8 bits per
// worker for the local sequence.
let seed = (test_r::worker_index() as u16 & 0xFF) << 8;
LastUniqueId { id: AtomicU16::new(seed) }
}
}
When the runner does not spawn workers — e.g. under --nocapture, when no test in the schedule requires capture, or when a Shared dep forces the single-thread fallback — worker_index() returns 0. This is the same value the top-level parent observes for itself.
Cloneable
Implement the CloneableDep trait for the dependency type, then annotate the constructor with scope = Cloneable:
#![allow(unused)]
fn main() {
use test_r::core::CloneableDep;
use test_r::{test, test_dep};
pub struct PrecomputedPayload {
pub bytes: Vec<u8>,
}
impl CloneableDep for PrecomputedPayload {
fn to_wire(&self) -> Vec<u8> {
self.bytes.clone()
}
fn from_wire(bytes: &[u8]) -> Self {
Self { bytes: bytes.to_vec() }
}
}
#[test_dep(scope = Cloneable)]
fn create_payload() -> PrecomputedPayload {
// Runs exactly once, in the parent process.
PrecomputedPayload { bytes: expensive_build() }
}
#[test]
fn uses_payload(payload: &PrecomputedPayload) {
assert_eq!(payload.bytes.len(), 1024 * 1024);
}
fn expensive_build() -> Vec<u8> { vec![0; 1024 * 1024] }
}
The wire encoding is entirely up to the implementor — there is no serde requirement. Cloneable wire payloads larger than 64 KiB are supported (the IPC framing uses a u32 length prefix).
Cloneable constructor dependencies
Cloneable constructors may take other #[test_dep] parameters. Those
constructor dependencies are resolved in the parent when test-r creates the
wire bytes. Worker subprocesses only receive the wire payload and call
CloneableDep::from_wire(bytes), so the reconstructed worker value is treated
as a dependency-free leaf.
If reconstruction needs worker-local state (for example, a per-worker engine to
interpret the bytes), keep that state in a separate PerWorker dependency and
combine the two in your test or in a higher-level helper.
Hosted
scope = Hosted keeps the owner alive in the parent for the whole
suite and lets each worker subprocess obtain its own view of that
single owner. The view shape is chosen at the registration site by the
optional worker = … argument; see the
Worker view subsection
for the full picker. The default — and the only one this top-of-section
example uses — is worker = descriptor: implement the
HostedDep
trait for the dependency type, then annotate the constructor with
scope = Hosted:
#![allow(unused)]
fn main() {
use std::net::{SocketAddr, TcpListener};
use std::sync::Arc;
use test_r::core::HostedDep;
use test_r::{test, test_dep};
pub struct LiveService {
addr: SocketAddr,
/// Owner-only: the parent holds the live listener for the whole suite.
/// Workers never populate this.
_listener: Option<Arc<TcpListener>>,
}
impl LiveService {
fn bind() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().unwrap();
// ... spawn an accept loop, etc.
Self { addr, _listener: Some(Arc::new(listener)) }
}
}
impl HostedDep for LiveService {
fn descriptor(&self) -> Vec<u8> {
// Ship just enough information for workers to reach the live owner.
self.addr.to_string().into_bytes()
}
fn from_descriptor(bytes: &[u8]) -> Self {
let s = std::str::from_utf8(bytes).expect("utf-8");
let addr: SocketAddr = s.parse().expect("addr");
Self { addr, _listener: None /* workers don't own the listener */ }
}
}
#[test_dep(scope = Hosted)]
fn live_service() -> LiveService {
// Runs exactly once, in the parent process. Stays alive for the
// entire suite — workers reach it via the descriptor.
LiveService::bind()
}
#[test]
fn worker_can_reach_owner(service: &LiveService) {
// service.addr points at the listener held by the parent.
// ...
}
}
How Hosted works
- The parent test runner calls the owner constructor once when it first builds the execution plan.
- The parent calls
HostedDep::descriptor()on the owner and ships the bytes to every worker via IPC (ProvideHostedDescriptor). - Each worker calls
HostedDep::from_descriptor(bytes)to materialise a local handle that knows how to reach the parent-held owner. - The parent keeps the owner alive (in
_hosted_owners) for the entire suite, so workers can rely on it being reachable as long as any test is still running. - When the suite finishes, the parent drops the owner — typically triggering whatever cleanup the owner’s
Dropimplementation needs (closing the listener, stopping a container, etc.).
The wire encoding is entirely up to the implementor, exactly like Cloneable. Descriptors are usually small — an address, a port, a credentials bundle.
Hosted constructor dependencies
- The constructor may take other
#[test_dep]parameters. Those dependencies are resolved only in the parent while constructing the owner. Workers receive descriptor bytes and callHostedDep::from_descriptor(bytes), so the reconstructed worker handle is a dependency-free leaf. - With
worker = descriptor(the default), the owner type and the worker handle type share the same Rust type (Self). The implementor is responsible for keeping owner-only fields (sockets, accept loops, container handles) inOptions orArcs that workers don’t populate. Theworker = rpc(Trait)andworker = both(Trait)views generate a separate<Trait>Stubfor the RPC side, so this invariant only applies to the descriptor view.
Worker view: descriptor, rpc(Trait), both(Trait)
scope = Hosted accepts an optional worker = … argument on
#[test_dep] that chooses what shape the worker subprocess sees
for the same parent-held owner:
worker = … | Worker-visible value | Owner trait(s) required |
|---|---|---|
descriptor (default) | HostedDep::from_descriptor(parent_descriptor_bytes) — a reconstructed handle (typically holding an address). | HostedDep (or AsyncHostedDep). |
rpc(Trait) | An auto-generated <Trait>Stub whose methods route each call back to the parent over the runtime’s IPC channel. | HostedRpcDep or AsyncHostedRpcDep implemented for the owner; trait declared with #[hosted_rpc]. Async mode is inferred from async fn methods. |
both(Trait) | Both a descriptor-shaped handle and a <Trait>Stub, backed by the same single owner instance. | Descriptor side: HostedDep or AsyncHostedDep. RPC side: HostedRpcDep or AsyncHostedRpcDep. Both impls are on the same owner type. |
In all three cases the parent constructs the owner exactly once and keeps it alive for the whole suite. Workers obtain their view through the existing IPC channel; nothing changes about parallelism or output capturing relative to the default Hosted strategy.
worker = descriptoris the implicit default. The historical#[test_dep(scope = Hosted)]syntax stays equivalent to#[test_dep(scope = Hosted, worker = descriptor)].worker = rpc(Trait)is shorthand for what used to be a separate#[test_dep(scope = HostedRpc, stub = <StubType>)]registration. See the#[hosted_rpc]attribute macro section for the trait-side machinery; the only difference at the registration site is which scope you use.worker = both(Trait)shares a singleton that needs to expose both a bulk-data descriptor handle (typically a connection address used by a gRPC client) and a small RPC control surface (kill / flush / snapshot) from a single owner. Tests can parameterise on either the descriptor type, the auto-generated<Trait>Stub, or both; no duplicate owner is constructed.
async_worker is no longer needed on any of these forms — the runtime
picks the sync vs async worker-side reconstructor automatically based
on the active runtime (sync vs tokio) and the dep’s
HostedDep / AsyncHostedDep implementation. The flag still parses
for source compatibility but emits a #[deprecated] warning at the
registration site.
Async worker-side reconstruction (AsyncHostedDep)
The default HostedDep::from_descriptor is synchronous. When worker-side
reconstruction needs to .await — opening async network clients,
calling async constructors of downstream services such as
ProvidedWorkerService::new(...).await — implement
AsyncHostedDep
instead. Under the tokio runtime feature the runtime automatically
drives every Hosted reconstruction through the async path, so no
extra #[test_dep] flag is required:
#![allow(unused)]
fn main() {
use test_r::core::AsyncHostedDep;
use test_r::{test, test_dep};
impl AsyncHostedDep for LiveAsyncService {
fn descriptor(&self) -> Vec<u8> {
self.addr.to_string().into_bytes()
}
async fn from_descriptor(bytes: &[u8]) -> Self {
let s = std::str::from_utf8(bytes).expect("utf-8");
let addr: SocketAddr = s.parse().expect("addr");
// Async work only legal because `from_descriptor` is async on
// `AsyncHostedDep`. The sync `HostedDep` flavour could not do this.
let stream = TcpStream::connect(addr).await.expect("connect");
Self { addr, prewarmed_client: Some(stream) }
}
}
#[test_dep(scope = Hosted)]
async fn live_async_service() -> LiveAsyncService {
LiveAsyncService::new().await
}
}
Semantics are otherwise identical to plain HostedDep:
- The parent still constructs the owner exactly once and ships
descriptor()bytes to every worker. - Each worker runs the async
from_descriptorinside aWorkerReconstructor::Asyncclosure on its tokio runtime. - The blanket
impl<T: HostedDep> AsyncHostedDep for Tmakes a syncHostedDepimplementation usable through the async path too, so switching the consumer crate to thetokiofeature does not require rewriting existing sync implementations.
Note —
async_workeris deprecated. The#[test_dep(scope = Hosted, async_worker)]attribute flag from earlier releases is no longer needed: the macro now selects the worker-side reconstruction path purely from the active runtime (sync vstokio) and the dep’sHostedDep/AsyncHostedDepimplementation. The flag still parses for source compatibility but emits a compile-time#[deprecated]warning at the registration site.
The
hosted_async_worker example
demonstrates the full pattern, including a regression test that confirms
the worker-side reconstructor actually runs only in worker subprocesses.
Mode-consistent semantics across --nocapture and worker mode
The test functions always see the worker-side handle produced by
HostedDep::from_descriptor, never the raw owner value, regardless of which
execution mode the runner ended up in:
- With output capturing on (the default), every test runs inside an IPC
worker subprocess and sees
from_descriptor(parent_descriptor_bytes). - With
--nocapture(or single-process mode), the runner still creates the owner exactly once in the parent, callsdescriptor()on it, and then locally appliesfrom_descriptorso the in-process tests see exactly the same kind of handle.
This means you can write HostedDep::from_descriptor as the single source
of truth for what a test-visible handle looks like, and you don’t need to
distinguish between “parent test run” and “worker test run” in your test
code.
HostedRpc
HostedRpc is the close sibling of Hosted for singletons whose
test-visible API is a small set of method calls rather than a network
endpoint. The owner lives in the top-level parent for the entire suite
and workers see a stub — a tiny Rust struct that serialises each
method call, ships it over the runtime’s IPC channel to the parent, and
unwraps the reply.
Preferred registration syntax —
scope = Hostedwithworker = rpc(Trait). The recommended way to register an RPC-shaped Hosted dep backed by a trait is#[test_dep(scope = Hosted, worker = rpc(<Trait>))]. That syntax uses the same runtime mechanism documented in this section but drops the explicitstub = <StubType>argument: the macro derives the worker-visible stub type from the trait name and writes the registration entry for you. Use it together with the#[hosted_rpc]attribute macro on a normal Rust trait.The legacy
#[test_dep(scope = HostedRpc, stub = <StubType>)]form remains supported and continues to be the right choice when there is no Rust trait surface — for example when you ship a hand-written stub with custom method indices, custom argument framing, or no trait declaration at all. The twohosted_rpc_basicexamples are intentionally kept on the legacy form for that reason.
Use this when:
- the dep is a singleton (an id allocator, a leadership coordinator, a globally-shared counter, …),
- you don’t want to invent and embed a real network protocol for tests,
- a few hundred call-per-test of overhead per RPC are acceptable (every call is one synchronous round-trip on the existing IPC socket).
Supported scope
- Both runners are supported. Tests in both runners see the same
Stubvalue and call into the same parent-held owner via the IPC transport (orInProcessHostedRpcTransportin the--nocapture/ no-spawn-workers path). - The user implements the owner type, the worker-visible stub type, and
one method-dispatch function on the owner. The runtime wires those
together over IPC. For trait-shaped owners, the
#[hosted_rpc]attribute macro generates the stub struct, the per-methoddesert_rustencode/decode shims and the dispatch arms for you. - One in-flight call at a time per worker subprocess. Each
stub.foo()takes the IPC connection lock, writes the request frame, reads exactly one reply frame, and returns. No multiplexer or out-of-order replies. - Stub methods can be either synchronous or
async fn— the shape is inferred from the trait declaration:- A trait whose methods are all plain sync
fns produces a sync stub and the owner implementsHostedRpcDep. Under the tokio runner the sync stub method is still bridged to the async IPC primitives viatokio::task::block_in_place+Handle::current().block_on(...). - A trait whose methods are all
async fns produces an async stub and the owner implementsAsyncHostedRpcDep. Under the tokio runner the parent dispatches through the async owner directly (noblock_onbridge). - Mixing sync and
async fnmethods in the same trait is rejected at macro time. There is no explicit#[hosted_rpc(async)]flag — async mode is auto-detected from the methods.
- A trait whose methods are all plain sync
HostedRpcDep (the trait)
#![allow(unused)]
fn main() {
use test_r::core::{HostedRpcChannel, HostedRpcDep, HostedRpcError};
pub struct LastUniqueIdOwner { counter: std::sync::Mutex<u64> }
const METHOD_NEXT: u32 = 1;
impl HostedRpcDep for LastUniqueIdOwner {
/// The worker-visible handle tests parameterise on.
type Stub = LastUniqueIdStub;
/// Owner-side dispatcher. `method_idx` is a stable, user-chosen
/// index per method (you pick the numbering). `args` is the raw
/// serialised payload; the choice of codec is yours.
fn dispatch(&mut self, method_idx: u32, _args: &[u8]) -> Result<Vec<u8>, String> {
match method_idx {
METHOD_NEXT => {
let mut guard = self.counter.lock().map_err(|e| e.to_string())?;
*guard += 1;
Ok(guard.to_be_bytes().to_vec())
}
other => Err(format!("unknown method_idx {other}")),
}
}
/// Worker-side stub builder. The runtime hands you a fresh
/// `HostedRpcChannel` tagged with this dep's fully-qualified id
/// once per worker; you wrap it in your stub.
fn build_stub(channel: HostedRpcChannel) -> Self::Stub {
LastUniqueIdStub { channel }
}
}
pub struct LastUniqueIdStub { channel: HostedRpcChannel }
impl LastUniqueIdStub {
pub fn next(&self) -> Result<u64, HostedRpcError> {
let bytes = self.channel.call(METHOD_NEXT, Vec::new())?;
let arr: [u8; 8] = bytes.as_slice().try_into()
.map_err(|e| HostedRpcError::Transport(format!("{e}")))?;
Ok(u64::from_be_bytes(arr))
}
}
#[test_dep(scope = HostedRpc, stub = LastUniqueIdStub)]
fn unique_id_owner() -> LastUniqueIdOwner {
LastUniqueIdOwner { counter: std::sync::Mutex::new(0) }
}
#[test]
fn ids_are_unique(ids: &LastUniqueIdStub) {
let a = ids.next().unwrap();
let b = ids.next().unwrap();
assert!(a < b);
}
}
The stub = StubType attribute is required. The constructor returns the
owner type, the test parameter is the stub type; the macro
registers the dep under the stub’s type name so test parameter resolution
finds it.
#[hosted_rpc] attribute macro: eliminating the boilerplate
Writing the LastUniqueIdStub struct, the per-method argument
serialisation and the match method_idx { ... } arm in
HostedRpcDep::dispatch is mechanical work. The #[hosted_rpc]
attribute macro generates all of it from a user trait declaration.
#![allow(unused)]
fn main() {
use test_r::core::{HostedRpcChannel, HostedRpcDep};
use test_r::{hosted_rpc, test, test_dep};
#[hosted_rpc]
pub trait Counter {
fn next(&self) -> u64;
fn reserve(&self, count: u32) -> u64;
fn echo(&self, msg: String) -> String;
}
pub struct CounterOwner { counter: std::sync::Mutex<u64> }
impl Counter for CounterOwner {
fn next(&self) -> u64 {
let mut g = self.counter.lock().unwrap();
*g += 1; *g
}
fn reserve(&self, count: u32) -> u64 {
let mut g = self.counter.lock().unwrap();
let first = *g + 1; *g += count as u64; first
}
fn echo(&self, msg: String) -> String { msg }
}
impl HostedRpcDep for CounterOwner {
type Stub = CounterStub;
fn dispatch(&mut self, method_idx: u32, args: &[u8]) -> Result<Vec<u8>, String> {
// Generated by `#[hosted_rpc]`. Routes the wire `method_idx` to
// the matching method on `self`, decoding args / encoding the
// reply with `desert_rust`.
CounterDispatch::dispatch_counter(self, method_idx, args)
}
fn build_stub(channel: HostedRpcChannel) -> Self::Stub {
// Generated by `#[hosted_rpc]`. Wraps the `HostedRpcChannel` in
// the worker-side stub that implements `Counter`.
CounterStub::new(channel)
}
}
#[test_dep(scope = Hosted, worker = rpc(Counter))]
fn counter_owner() -> CounterOwner {
CounterOwner { counter: std::sync::Mutex::new(0) }
}
#[test]
fn ids_are_monotonic(c: &CounterStub) {
let a = c.next();
let b = c.next();
assert!(a < b);
}
}
scope = Hosted, worker = rpc(Counter) is the preferred
registration form for trait-shaped owners. The macro derives the
worker-visible stub type from the trait name (Counter → CounterStub)
so you do not need to pass it explicitly. The equivalent legacy form
#[test_dep(scope = HostedRpc, stub = CounterStub)] still works and
remains the right choice for hand-written stubs that are not backed by
a trait at all (see the HostedRpcDep
example above).
Async #[hosted_rpc] traits
If every method in the trait is declared async fn, the macro
switches to async mode automatically. The generated stub
methods preserve the async fn signature, the generated dispatch
helper becomes async fn dispatch_<snake>(...), and the owner
implements AsyncHostedRpcDep
instead of HostedRpcDep.
There is no #[hosted_rpc(async)] flag — the mode is inferred
from the trait declaration. Mixing sync and async fn methods in
the same trait is a compile error.
#![allow(unused)]
fn main() {
use std::time::Duration;
use test_r::core::{AsyncHostedRpcDep, HostedRpcChannel};
use test_r::{hosted_rpc, test, test_dep};
use tokio::sync::Mutex;
#[hosted_rpc]
pub trait AsyncCounter {
async fn next(&self) -> u64;
async fn add(&self, a: u32, b: u32) -> u64;
}
pub struct AsyncCounterOwner { counter: Mutex<u64> }
impl AsyncCounter for AsyncCounterOwner {
async fn next(&self) -> u64 {
tokio::time::sleep(Duration::from_millis(1)).await;
let mut g = self.counter.lock().await;
*g += 1; *g
}
async fn add(&self, a: u32, b: u32) -> u64 {
tokio::task::yield_now().await;
a as u64 + b as u64
}
}
impl AsyncHostedRpcDep for AsyncCounterOwner {
type Stub = AsyncCounterStub;
async fn dispatch(&mut self, method_idx: u32, args: &[u8])
-> Result<Vec<u8>, String>
{
AsyncCounterDispatch::dispatch_async_counter(self, method_idx, args).await
}
fn build_stub(channel: HostedRpcChannel) -> Self::Stub {
AsyncCounterStub::new(channel)
}
}
#[test_dep(scope = Hosted, worker = rpc(AsyncCounter))]
fn async_counter_owner() -> AsyncCounterOwner {
AsyncCounterOwner { counter: Mutex::new(0) }
}
#[test]
async fn async_stub_round_trips(c: &AsyncCounterStub) {
let id = c.next().await;
let sum = c.add(7, 35).await;
assert!(id > 0);
assert_eq!(sum, 42);
}
}
The registration syntax is identical to the sync case:
#[test_dep(scope = Hosted, worker = rpc(AsyncCounter))]. The
sync–or–async choice flows from the trait declaration only. A
blanket impl<T: HostedRpcDep> AsyncHostedRpcDep for T also exists,
so under the tokio runner a sync owner can be reused unchanged in
contexts that require an async dispatcher. See
hosted_rpc_macro_async
for the full runnable version.
What the macro emits next to the trait declaration:
- A struct
<Trait>Stub { channel: HostedRpcChannel }with apub fn new(channel) -> Selfconstructor and animpl <Trait> for <Trait>Stubthat implements every trait method by encoding the args as a tuple of the parameter types (1-arg methods send the bare value; 0-arg methods send(); 2+-arg methods send a regular tuple), shipping them throughHostedRpcChannel::call(method_idx, ...)and decoding the return value. Encoding usesdesert_rust. - A trait
<Trait>Dispatchwith a single methoddispatch_<snake_case_trait_name>(&mut self, method_idx: u32, args: &[u8]) -> Result<Vec<u8>, String>, blanket-implemented for everyT: <Trait>, that contains the per-method match arms wiring incoming RPCs back to the owner’s<Trait>impl. The owner’sHostedRpcDep::dispatchbecomes a one-line delegation.
Wire-format details:
- Method indices are assigned by source order in the trait, starting at
0, and shipped on the wire asu32. Reordering the methods is a breaking change. - Args are encoded with
desert_rust::serialize_to_byte_vecas a tuple of the parameter types after strippingself. The zero-arg case uses(); the single-arg case uses the bareT(NOT a 1-tuple) so the framing stays symmetric on the dispatch side. - The return value is encoded directly. The unit return type uses
().
Restrictions enforced at macro time (the macro emits a
compile_error! if violated):
#[hosted_rpc]does not take any attribute arguments (#[hosted_rpc(...)]).- The trait must be non-generic, must not be
unsafe trait, must not have supertraits, and must only declare methods (no associatedtype/constitems). - Methods must be non-generic, must not be
unsafe fn, must use the default Rust ABI (noextern "..."), must not be variadic, must not have a default body, and the first argument must be&self(no by-valueself, no explicitself: Ttype, and no&mut selfeither — test-r injects test deps as&Stubimmutable references, so&mut selfstub methods would compile but be uncallable from a normal#[test] fn (s: &MyStub)parameter). - Methods may be either all plain sync
fnor allasync fn. Mixing sync andasync fnmethods in the same#[hosted_rpc]trait is rejected at macro time. Async mode is inferred from the methods — there is no#[hosted_rpc(async)]flag. - Argument types must use plain identifier patterns (no
_, no destructuring like(a, b): (u32, u32)). impl Traitis not allowed in argument or return position.#[cfg(...)]/#[cfg_attr(...)]are not allowed on the trait or its methods (the generated sibling items and dispatch arms are not cfg-propagated.
All arg and return types must implement desert_rust::BinarySerializer
and desert_rust::BinaryDeserializer. Common standard-library types
(u8/u16/u32/u64/i*, bool, String, Vec<T>, Option<T>,
HashMap<K, V> and N-ary tuples for N >= 2) already do.
Transport, codec and dispatch failures (IPC errors, owner panics,
encode/decode errors) panic in the generated stub with an
expect(...) message of the form hosted_rpc(<Trait>::<method>): ....
User-level errors are still encoded normally: if a trait method
returns Result<T, E>, the Result itself is shipped over the wire
and only infrastructure failures panic.
How HostedRpc works
- The parent test runner calls the owner constructor once when it
first builds the execution plan, wraps the result in an internal
HostedRpcOwnerCell, and keeps it alive for the whole suite. - Each worker subprocess builds a stub via
build_stub(channel)using an IPC-backedHostedRpcChannelkeyed to the dep’s fully-qualified id. - When a test calls
stub.foo(args), the stub sends anIpcResponse::HostedRpcCallframe on the shared IPC socket and blocks for the matchingIpcCommand::HostedRpcReply. - The parent’s worker dispatch loop intercepts incoming
HostedRpcCallframes, looks up the rightHostedRpcOwnerCellby dep id, runs the owner’sdispatch(method_idx, &args)behind aMutex, and writes the reply back. Sync owners run behind astd::sync::Mutex; under the tokio runner async owners run behind atokio::sync::Mutexand the parent loop awaitsHostedRpcOwnerCell::dispatch_async(...)directly so userasync fnmethods can.await. - Owner panics are caught by
HostedRpcOwnerCell::dispatch/dispatch_asyncand surfaced to the calling worker asHostedRpcError::Dispatch("hosted rpc owner panicked: …"). Sync cells rely on the standardstd::sync::Mutexpoisoning; async cells use an out-of-bandAtomicBoolpoison flag that is re-checked inside the lock so a waiter parked onlock().awaitwhen the previous dispatch panics still returns the stable"hosted rpc owner poisoned"error instead of re-entering the (possibly half-mutated) owner. A single bad call doesn’t bring down the rest of the suite. - In
--nocapture/ single-process mode, the runtime swaps the IPC-backed transport forInProcessHostedRpcTransport, which calls the owner cell directly — tests see the same stub regardless of execution mode.
HostedRpc restrictions
- The stub trait methods may be either synchronous or
async fn, but all methods of a single#[hosted_rpc]trait must use the same shape. Async-mode traits require the owner to implementAsyncHostedRpcDep; sync-mode traits continue to useHostedRpcDep. Async#[hosted_rpc]registrations are only usable under the tokio runner; the trait itself is re-exported unconditionally but the runtime async dispatch path (theAsyncowner cell,dispatch_async,dispatch_blocking) istokio-feature-gated. Without the tokio feature, owners must implementHostedRpcDep. The blanketimpl<T: HostedRpcDep> AsyncHostedRpcDep for Talso lets a sync owner be plugged into the tokio runner’s async dispatch path without further code changes. - The constructor may take other
#[test_dep]parameters. Those dependencies are resolved only in the parent while constructing the owner. Workers receive an RPC channel and callHostedRpcDep::build_stub(channel), so the reconstructed stub is a dependency-free leaf. - The constructor must return the owner type; tests must parameterise
on the stub type named via
stub = StubType. - One in-flight RPC at a time per worker subprocess. Pipelined or concurrent calls are not supported in the MVP.
- The tokio HostedRpc transport relies on the runner being driven by a
multi-thread tokio runtime so
tokio::task::block_in_place/Handle::current().block_on(...)can re-enter the IPC I/O from the sync stub trait method. The built-in test-r tokio runner satisfies this; a custom runner on acurrent_threadruntime is not supported.
MVP temporal invariant — when stub calls are safe
The HostedRpc transport intentionally reuses the same IPC socket the
harness uses for RunTest / ProvideCloneable /
ProvideHostedDescriptor traffic. Stubs share that socket with the
worker’s main IPC command loop, and they take a process-local mutex
around one full request/response. The IPC framing only stays in sync
because of two assumptions:
-
Stubs are only invoked from inside the test body. Tests in the worker subprocess only run between
RunTest(parent → worker) andTestFinished(worker → parent). During that window the worker’s main command loop is idle (it doesn’t read from the socket), so the stub’s request/reply round-trip is the only traffic in flight. -
HostedRpcDep::build_stubis cheap and side-effect free. It runs once per worker subprocess at startup, before the worker has received its firstRunTest. Ifbuild_stubitself calledchannel.call(...), the parent could legally send aRunTestwhile the stub was blocked waiting for a reply, and the transport would read thatRunTestas if it were aHostedRpcReply.
Concretely:
- ✅ Calling
stub.foo(...)from inside a#[test]function body, or from any helper the test body awaits or blocks on, is safe. - ❌ Calling
channel.call(...)fromHostedRpcDep::build_stubis not supported and will desync the IPC framing. - ❌ Calling
stub.foo(...)from a detached background thread that outlives the test body is not supported — once the test returns, the worker’s next IPC traffic isTestFinishedfollowed by eitherRunTestorProvide*from the parent, not aHostedRpcReply. - ❌ Calling
stub.foo(...)fromDrop/ destructor-style cleanup, or from any teardown hook that may fire after the test body has returned, is not supported for the same reason — the rule is “inside the test body, every time”.
If you need any of the unsupported shapes, treat that as a signal to either restructure your test (run the work inside the test body, wait for the background thread to finish before returning) or use a future transport design with a dedicated worker-side reader and a waiter table.
When to prefer Hosted over HostedRpc
If your dep already exposes a network endpoint (TCP listener, gRPC
server, Docker container with a published port), use Hosted and ship
the address as the descriptor. HostedRpc is the right choice when no
such endpoint exists and you don’t want to invent one.
When does the single-thread fallback still kick in?
The parallel/single-thread decision is made once, after the dep graph is known:
- If output capturing is off (
--nocapture), the runner never falls back. - If capturing is on and the suite has at least one
Shareddep, the runner falls back to one thread. - If capturing is on and all deps in scope are
PerWorker,Cloneable,Hosted, and/orHostedRpc, the runner stays parallel.
A suite that mixes Shared and any of the parallel-safe scopes will still fall back: Shared is the strictest scope in scope-mixing. Migrate the remaining Shared deps to a more permissive scope to recover parallelism.
Tags
Assigning tags
Tests can be associated with an arbitrary number of tags. Each tag is global, and must be a valid Rust identifier.
Tags can be assigned to tests using the #[tag] attribute:
#![allow(unused)]
fn main() {
use test_r::{tag, test};
#[tag(tag1)]
#[tag(tag2)]
#[test]
fn tagged_test() {
assert!(true);
}
}
Tagging entire test suites
It is possible to tag an entire test suite. This can be done by using the #[tag] attribute on the module containing the tests,
or alternatively using the tag_suite! macro:
#![allow(unused)]
fn main() {
use test_r::{tag, tag_suite, test};
mod inner1;
tag_suite!(inner1, tag1);
#[tag(tag2)]
mod inner2 {
// ...
}
}
The tag_suite! macro is necessary because currently it is not possible to put attributes on non-inlined modules.
Running tagged tests
The purpose of tagging tests is to run a subset of the crate’s tests selected by tags. To select tests by tags, use the
:tag: prefix when passing the test name to cargo test:
cargo test :tag:tag1
This example will run every test tagged as tag1, but no others.
Selecting untagged tests
Sometimes it is useful to select all tests without a tag. This can be done by using the :tag: prefix with no tag name:
cargo test :tag:
Selecting tests by multiple tags
Multiple tags can be combined with the | (or) and & (and) operators. The & operator has higher precedence than |. So the following example:
cargo test ':tag:tag1|tag2&tag3'
is going to run tests tagged as either tag1 or both tag2 and tag3.
Skipping tests by tag
The :tag: syntax also works with the --skip option. This allows skipping all tests with a specific tag:
cargo test -- --skip ':tag:slow'
This runs all tests except those tagged as slow.
Benches
test-r provides a simple benchmark runner as well, very similar to the built-in one in unstable Rust. The main differences are that test-r allows defining async bench functions too (when the tokio feature is enabled), and that benchmark functions also support dependency injection.
Defining benchmarks
To define a benchmark, just use the #[bench] attribute instead of a #[test] attribute on a function that takes a mutable reference to a Bencher:
#![allow(unused)]
fn main() {
use test_r::{bench, Bencher};
#[bench]
fn bench1(b: &mut Bencher) {
b.iter(|| 10 + 11);
}
}
The benchmark framework will measure the performance of the function passed to the iter method on the bencher.
If a benchmark needs shared dependencies, they can be added as additional parameters to the benchmark function. The &mut Bencher parameter must always be the first one.
#![allow(unused)]
fn main() {
use test_r::{bench, Bencher};
struct SharedDependency {
value: i32,
}
#[bench]
fn bench2(b: &mut Bencher, shared: &SharedDependency) {
b.iter(|| shared.value + 11);
}
}
Async benchmarks
When the tokio feature is enabled, benchmarks can be async too. Just use the #[bench] attribute on an async function that takes a mutable reference to an AsyncBencher:
#![allow(unused)]
fn main() {
use test_r::{bench, AsyncBencher};
#[bench]
async fn bench1(b: &mut AsyncBencher) {
b.iter(|| Box::pin(async { 10 + 11 })).await;
}
}
Running benchmarks
Benchmarks are run by default as part of cargo test, but they can be also separately executed using cargo bench, or by passing the --bench flag to cargo test.
Per-test configuration
Some aspects of the test runner can be enforced on a per-test or per-suite basis using special attributes, instead of relying on command line options.
Enforce sequential execution
Parallelism of the test runner is normally controlled by the --test-threads command line argument. It is possible to enforce sequential execution for all tests within a test suite by putting the #[sequential] attribute on the module representing the suite:
#![allow(unused)]
fn main() {
use test_r::{sequential, test};
#[sequential]
mod suite {
#[test]
fn test1() {
assert!(true);
}
#[test]
fn test2() {
assert!(true);
}
}
}
The rest of the tests in the crate will still be parallelized based on the --test-threads argument.
When #[sequential] is applied to a module that contains nested sub-modules, the sequential behavior propagates to the entire subtree. All tests in the module and its descendants are guaranteed to run one at a time, gated by a single lock.
The #[sequential] attribute can only be used on inline modules due to a limitation in the current stable Rust compiler.
For non-inline modules, you can use the sequential_suite! macro instead in the following way:
#![allow(unused)]
fn main() {
use test_r::sequential_suite;
mod suite;
sequential_suite!(suite);
}
Always or never capture output
Two attributes can enforce capturing or not capturing the standard output and error of a test. Without these attributes, the runner will either capture (by default), or not (if the --nocapture command line argument is passed).
When the #[always_capture] attribute is used on a #[test], the output will be captured even if the --nocapture argument is passed. Conversely, the #[never_capture] attribute will prevent capturing the output even if the --nocapture argument is not passed.
Timeout
The #[timeout(duration)] attribute can be used to enforce a timeout for a test. The timeout is specified in milliseconds as a number:
#![allow(unused)]
fn main() {
use test_r::{test, timeout};
#[timeout(1000)]
#[test]
async fn test1() {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
assert!(true);
}
}
Alternatively a human-readable duration string can be used, parsed by the humantime crate:
#![allow(unused)]
fn main() {
use test_r::{test, timeout};
#[timeout("1s")]
#[test]
async fn test1() {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
assert!(true);
}
}
This feature only works when using the async test runner (enabled by the tokio feature).
Suite-level timeout
It is possible to apply a timeout to all tests in a test suite by putting the #[timeout(duration)] attribute on the module:
#![allow(unused)]
fn main() {
use test_r::{test, timeout};
#[timeout("3s")]
mod suite {
use super::*;
#[test]
async fn test1() {
// This test will be timed out after 3 seconds
}
#[test]
#[timeout(60000)]
async fn test2() {
// This test overrides the suite timeout with its own
}
}
}
If an individual test within the suite has its own #[timeout] attribute, the per-test timeout takes precedence over the suite-level one.
The #[timeout] attribute can only be used on inline modules due to a limitation in the current stable Rust compiler.
For non-inline modules, you can use the timeout_suite! macro instead:
#![allow(unused)]
fn main() {
use test_r::timeout_suite;
mod suite;
timeout_suite!(suite, "3s");
}
The second parameter of timeout_suite! accepts the same values as the #[timeout] attribute: an integer (milliseconds) or a human-readable duration string.
Reporting / ensuring time per test
There are command line arguments to enable reporting test run times and ensuring that each test runs within a certain time limit. The command line arguments enable these features for all tests. It is possible to individually configure this behavior per test using the following attributes:
#[always_report_time]will report the time taken by the test even if the--report-timeargument is not passed.#[never_report_time]will prevent reporting the time taken by the test even if the--report-timeargument is passed.#[always_ensure_time]will ensure that the test runs within the specified duration even if the--ensure-timeargument is not passed.#[never_ensure_time]will ignore the--ensure-timeargument for this test
Note that for ensuring time, it is not possible to overwrite the global time limit set using environment variables, which is the way the built-in Rust test runner works. For better control, use the #[timeout(duration)] attribute instead.
Working with flaky tests
Tests can be sometimes flaky, and only fail sporadically or depending on the environment or hardware they run on.
test-r provides two ways to handle flaky tests:
Marking tests as known to be flaky
By using the #[flaky(n)] attribute, where n is a number, we acknowledge that a test is known to be flaky, and the test runner will retry it up to n times before marking it as failed.
#![allow(unused)]
fn main() {
use test_r::{flaky, test};
#[flaky(3)]
#[test]
fn flaky_test() {
assert!(false); // This test will fail 3 times before being marked as failed
}
}
Ensuring tests are not flaky
The opposite appraoch is to ensure that a test is not flaky by running it multiple times. This can help in diagnosing flakiness and reproducing issues locally. The #[non_flaky(n)] attribute will run a test n times before marking it as succeeded.
#![allow(unused)]
fn main() {
use test_r::{non_flaky, test};
#[non_flaky(3)]
#[test]
fn non_flaky_test() {
assert!(true); // This test will pass 3 times before being marked as succeeded
}
}
Dynamic test generation
Normally the test tree is static, defined compile time using modules representing test suites and functions annotated with #[test] defining test cases. Sometimes however it is useful to generate test cases runtime. test-r supports this using the #[test_gen] attribute.
Test generators can be either sync or async (if the tokio feature is enabled). The generator function must take a single parameter, a mutable reference to DynamicTestRegistration. Dependency injection to the generator function is not supported currently, but the dynamically generated tests can use shared dependencies.
The following two examples demonstrate generating sync and async tests using the #[test_gen] attribute:
#![allow(unused)]
fn main() {
use test_r::core::{DynamicTestRegistration, TestType};
use test_r::{add_test, test_gen};
struct Dep1 {
value: i32,
}
struct Dep2 {
value: i32,
}
#[test_gen]
fn gen_sync_tests(r: &mut DynamicTestRegistration) {
println!("Generating some tests with dependencies in a sync generator");
for i in 0..10 {
add_test!(
r,
format!("test_{i}"),
TestType::UnitTest,
move |dep1: &Dep1| {
println!("Running test {} using dep {}", i, dep1.value);
let s = i.to_string();
let i2 = s.parse::<i32>().unwrap();
assert_eq!(i, i2);
}
);
}
}
#[test_gen]
async fn gen_async_tests(r: &mut DynamicTestRegistration) {
println!("Generating some async tests with dependencies in a sync generator");
for i in 0..10 {
add_test!(
r,
format!("test_{i}"),
TestType::UnitTest,
move |dep1: &Dep1, d2: &Dep2| async {
println!("Running test {} using deps {} {}", i, dep1.value, d2.value);
let s = i.to_string();
let i2 = s.parse::<i32>().unwrap();
assert_eq!(i, i2);
}
);
}
}
}
The closure parameters in add_test! support #[tagged_as] to reference tagged dependencies, just like regular test functions:
#![allow(unused)]
fn main() {
add_test!(
r,
format!("test_{i}"),
TestType::UnitTest,
move |dep1: &Dep1, #[tagged_as("secondary")] d2: &Dep2| {
println!("Using tagged dep: {}", d2.value);
}
);
}
The generator functions are executed at the startup of the test runner, and all the generated tests are added to the test tree. The name of the generated tests must be unique. Each test is added to the test suite the generator function is defined in.
Detached panic detection
When a test spawns threads or async tasks that outlive the test body, panics in those detached contexts are normally invisible — the test passes while the panic message is silently printed to stderr. test-r can detect these panics and fail the test, but only when using test-r’s own spawn functions.
Spawn functions
test-r provides two spawn functions that propagate test context and capture panics:
test_r::spawn_thread— wrapsstd::thread::spawntest_r::spawn— wrapstokio::spawn(requires thetokiofeature)
These are drop-in replacements with the same signatures:
#![allow(unused)]
fn main() {
use test_r::{test, spawn_thread};
#[test]
fn test_with_background_thread() {
let handle = spawn_thread(|| {
// If this panics, the test will fail
assert_eq!(2 + 2, 4);
});
handle.join().unwrap();
}
}
#![allow(unused)]
fn main() {
use test_r::{test, spawn};
#[test]
async fn test_with_spawned_task() {
let handle = spawn(async {
// If this panics, the test will fail
assert_eq!(2 + 2, 4);
});
handle.await.unwrap();
}
}
How it works
By default, every test uses DetachedPanicPolicy::FailTest. When you use spawn_thread or spawn:
- The current test’s identity is propagated to the new thread or task.
- The closure is wrapped in
catch_unwindto intercept any panic. - If a panic occurs, it is recorded and associated with the originating test.
- After the test body completes, the runner checks for collected panics and fails the test if any were found.
Important: If you use
std::thread::spawnortokio::spawndirectly, panics in those threads/tasks will not be detected by test-r. They will be printed to stderr by the default panic hook but will not cause the test to fail.
Opting out
If a test intentionally spawns work that may panic, you can disable detection with the #[ignore_detached_panics] attribute:
#![allow(unused)]
fn main() {
use test_r::{test, ignore_detached_panics, spawn_thread};
#[ignore_detached_panics]
#[test]
fn test_that_expects_detached_panics() {
spawn_thread(|| {
panic!("this is expected");
});
}
}
How to
This section contains a set of recommendations to solve various testing problems using a combination of test-r and other third party crates.
- Tracing shows how to set up Tokio tracing for tests.
- Property based testing demonstrates how to use proptest framework with
test-r. - Golden tests are a way to compare the output of a test with a reference file.
- GitHub Actions with JUnit explains how to run tests on GitHub Actions and show the results using the JUnit output format.
Tracing
Subscribers for Tokio tracing usually need to be set up once at the beginning of the application, and further calls to their initialization functions may cause panics.
With test-r, the shared dependency feature can be used to set up the tracing subscriber once before the first test is executed, and keep it alive until the end of the test run.
The following example demonstrates this using the tracing-subscriber crate:
#![allow(unused)]
fn main() {
use test_r::{test_dep, test};
struct Tracing;
impl Tracing {
pub fn init() -> Self {
tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer().pretty()
).init();
Self
}
}
#[test_dep]
fn tracing() -> Tracing {
Tracing::init()
}
#[test]
fn test1(_tracing: &Tracing) {
tracing::info!("test1");
}
#[test]
fn test2(_tracing: &Tracing) {
tracing::info!("test2");
}
}
Property based testing
Property based testing using the proptest crate
The proptest library works well together with test-r. There is no special requirements, just make sure to import test-r’s test attribute before using the proptest! macro to define the property based tests.
For example:
#![allow(unused)]
fn main() {
use test_r::test;
use proptest::prelude::*;
fn parse_date(s: &str) -> Option<(u32, u32, u32)> {
todo!()
}
proptest! {
#[test]
fn parses_all_valid_dates(s in "[0-9]{4}-[0-9]{2}-[0-9]{2}") {
parse_date(&s);
}
}
}
Golden tests
Golden tests are comparing a previously saved output for a given test with the current output. This can be very useful to verify backward compatibility, for example. There are several golden testing libraries available in the Rust ecosystem.
The test-r crate does not provide a built-in support for golden tests, but it should work with most of these libraries.
Golden tests with the goldenfile crate
The goldenfile crate is proven to work well with test-r. For example the following helper function can be used to check the backward compatibility of reading serialized binary data with some custom serialize/deserialize functions requiring desert_rust codecs:
#![allow(unused)]
fn main() {
use desert_rust::{BinaryCodec, BinarySerializer, BinaryDeserializer, serialize_to_byte_vec, deserialize};
use goldenfile::Mint;
use test_r::test;
fn is_deserializable<T: BinaryCodec + PartialEq + Debug>(old: &Path, new: &Path) {
let old = std::fs::read(old).unwrap();
let new = std::fs::read(new).unwrap();
// Both the old and the latest binary can be deserialized
let old_decoded: T = deserialize(&old).unwrap();
let new_decoded: T = deserialize(&new).unwrap();
// And they represent the same value
assert_eq!(old_decoded, new_decoded);
}
pub(crate) fn backward_compatible<T: BinaryCodec + PartialEq + Debug + 'static>(
name: impl AsRef<str>,
mint: &mut Mint,
value: T,
) {
let mut file = mint
.new_goldenfile_with_differ(
format!("{}.bin", name.as_ref()),
Box::new(is_deserializable::<T>),
)
.unwrap();
let encoded = serialize_to_byte_vec(&value).unwrap();
file.write_all(&encoded).unwrap();
file.flush().unwrap();
}
#[derive(Debug, PartialEq, BinaryCodec)]
struct Example {
value: i32,
}
#[test]
pub fn example() {
let mut mint = Mint::new("tests/goldenfiles");
backward_compatible("example1", &mut mint, Example { value: 42 });
}
}
GitHub Actions with JUnit
With test-r it is easy to generate JUnit test reports when running the tests on CI. Then the generated XMLs can be parsed by another GitHub Action step to provide a nicer test report in the GitHub UI.
The following example shows how to run the tests with test-r and generate JUnit XMLs:
cargo test -- --format junit --logfile target/report.xml
This will generate one or more JUnit XML files in the target directory.
The action-junit-report action can be used to parse the generated XMLs and show the results in the GitHub UI. The following example shows how to use it:
- name: Publish Test Report
uses: mikepenz/action-junit-report@v4
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/target/report-*.xml'
detailed_summary: true
include_passed: true