Fast, deterministic tests for async services: no real network, no real time, no sleeps. Examples target
tokio 1 (with the test-util feature for time control), axum 0.8 and tower 0.5 (util feature for
ServiceExt::oneshot).
Contents
- Test runtimes
- Controlling time
- Router tests without a socket
- Fakes through traits in state
- Cancellation and shutdown
- Channels and backpressure
- Send assertions
- End-to-end over a real port
- Databases and HTTP dependencies
- Checklist
Test runtimes
| Attribute | Runtime | Use for |
|---|---|---|
#[tokio::test] | current_thread | most tests: deterministic, no Send requirement on locals |
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] | multi_thread | code using block_in_place, real parallelism, races |
#[tokio::test(start_paused = true)] | current_thread, paused clock | timeouts, retries, intervals, backoff |
Dev-dependency for time control: tokio = { version = "1", features = ["test-util", "macros", "rt"] }.
Controlling time
With a paused clock, sleep, timeout and interval complete as soon as every task is idle: the runtime
auto-advances to the next timer. A test of a 30-second timeout takes microseconds.
use std::time::Duration;
use tokio::time::{self, Instant};
async fn retry_with_backoff<F, Fut>(mut op: F, attempts: u32) -> Result<u32, &'static str>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<u32, &'static str>>,
{
let mut delay = Duration::from_millis(100);
for _ in 0..attempts {
match op().await {
Ok(v) => return Ok(v),
Err(_) => {
time::sleep(delay).await;
delay *= 2;
}
}
}
Err("gave up")
}
#[tokio::test(start_paused = true)]
async fn backoff_waits_100_200_400ms() {
let start = Instant::now();
let mut calls = 0;
let res = retry_with_backoff(|| { calls += 1; async { Err("down") } }, 3).await;
assert_eq!(res, Err("gave up"));
assert_eq!(start.elapsed(), Duration::from_millis(700)); // exact: virtual time
}
#[tokio::test(start_paused = true)]
async fn timeout_fires_without_waiting() {
let slow = time::sleep(Duration::from_secs(30));
assert!(time::timeout(Duration::from_secs(5), slow).await.is_err());
}Use time::advance(d).await to move the clock by hand when a test must observe intermediate states.
Real I/O (sockets, files) is not virtual: keep it out of paused-clock tests.
Router tests without a socket
An axum Router is a tower Service: call it directly with oneshot.
use axum::{body::{to_bytes, Body}, extract::Path, http::{Request, StatusCode}, routing::get, Json, Router};
use tower::ServiceExt; // oneshot
async fn get_item(Path(id): Path<u64>) -> Result<Json<serde_json::Value>, StatusCode> {
if id == 0 { Err(StatusCode::NOT_FOUND) } else { Ok(Json(serde_json::json!({ "id": id }))) }
}
fn app() -> Router {
Router::new().route("/items/{id}", get(get_item))
}
#[tokio::test]
async fn returns_item_json() {
let res = app().oneshot(Request::get("/items/7").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = to_bytes(res.into_body(), 64 * 1024).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], 7);
}
#[tokio::test]
async fn zero_is_not_found() {
let res = app().oneshot(Request::get("/items/0").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}oneshot consumes the router; build a fresh one per request (cheap) or clone it. For several requests
against one router use ServiceExt::ready + call, or wrap state in Arc and rebuild.
Fakes through traits in state
Put external dependencies behind a trait in AppState so tests swap in an in-memory fake:
use std::{collections::HashMap, sync::{Arc, Mutex}};
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Router};
pub trait Prices: Send + Sync {
fn price(&self, sku: &str) -> Option<u64>;
}
#[derive(Clone)]
pub struct AppState { prices: Arc<dyn Prices> }
async fn price(State(s): State<AppState>, Path(sku): Path<String>) -> Result<String, StatusCode> {
s.prices.price(&sku).map(|p| p.to_string()).ok_or(StatusCode::NOT_FOUND)
}
pub fn router(state: AppState) -> Router {
Router::new().route("/price/{sku}", get(price)).with_state(state)
}
#[derive(Default)]
struct FakePrices(Mutex<HashMap<String, u64>>);
impl Prices for FakePrices {
fn price(&self, sku: &str) -> Option<u64> {
self.0.lock().unwrap().get(sku).copied()
}
}
#[tokio::test]
async fn uses_the_fake() {
use axum::{body::Body, http::Request};
use tower::ServiceExt;
let fake = FakePrices::default();
fake.0.lock().unwrap().insert("apple".into(), 42);
let app = router(AppState { prices: Arc::new(fake) });
let res = app.oneshot(Request::get("/price/apple").body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}For async dependencies use async fn in the trait with a Send bound on the returned future (see
send-static-and-compile-errors.md) or a boxed future, so Arc<dyn Trait> stays possible.
Cancellation and shutdown
use std::time::Duration;
use tokio_util::{sync::CancellationToken, task::TaskTracker};
fn spawn_worker(tracker: &TaskTracker, token: CancellationToken) {
tracker.spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(1));
loop {
tokio::select! {
_ = token.cancelled() => break,
_ = tick.tick() => {}
}
}
});
}
#[tokio::test(start_paused = true)]
async fn workers_stop_on_cancel() {
let (tracker, token) = (TaskTracker::new(), CancellationToken::new());
for _ in 0..3 {
spawn_worker(&tracker, token.child_token());
}
token.cancel();
tracker.close();
tokio::time::timeout(Duration::from_secs(1), tracker.wait()).await.expect("workers stopped");
assert!(tracker.is_empty());
}Test that a dropped future cleans up (cancellation by drop) with a guard type whose Drop records it, and
abort a JoinHandle to check JoinError::is_cancelled().
Channels and backpressure
use tokio::sync::mpsc::{self, error::TrySendError};
#[tokio::test]
async fn bounded_channel_pushes_back() {
let (tx, mut rx) = mpsc::channel::<u32>(2);
tx.try_send(1).unwrap();
tx.try_send(2).unwrap();
assert!(matches!(tx.try_send(3), Err(TrySendError::Full(3)))); // producer must wait or drop
assert_eq!(rx.recv().await, Some(1));
tx.try_send(3).unwrap(); // room again
}Send assertions
tokio::spawn needs Send futures; catch regressions in your crate rather than at the spawn site:
fn assert_send<T: Send>(_: &T) {}
async fn handler_logic(state: std::sync::Arc<std::sync::Mutex<Vec<u32>>>) {
{
let mut g = state.lock().unwrap();
g.push(1);
} // guard dropped before the await
tokio::task::yield_now().await;
}
#[test]
fn handler_future_is_send() {
let fut = handler_logic(Default::default());
assert_send(&fut); // fails to compile if a !Send value lives across an .await
}End-to-end over a real port
Bind to port 0 so tests run in parallel, then talk HTTP. Useful for middleware that needs a real connection (TLS, timeouts on the socket, graceful shutdown).
#[tokio::test]
async fn serves_over_tcp() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let token = tokio_util::sync::CancellationToken::new();
let server = tokio::spawn({
let token = token.clone();
async move {
axum::serve(listener, app()).with_graceful_shutdown(token.cancelled_owned()).await.unwrap();
}
});
let body = reqwest::get(format!("http://{addr}/healthz")).await.unwrap().text().await.unwrap();
assert_eq!(body, "ok");
token.cancel();
server.await.unwrap(); // graceful shutdown completed
}Databases and HTTP dependencies
- sqlx:
#[sqlx::test]creates a fresh test database per test (fromDATABASE_URL) and runs migrations; the test receives aPgPool. Keep pure logic out of the database layer so most tests do not need it. - HTTP upstreams:
wiremockstarts a local mock server per test (Mock::given(method("GET")).respond_with(..)); point the client's base URL atmock_server.uri(). - Never call real third-party services from unit tests; use contract tests in a separate job.
Checklist
| Behaviour | Test |
|---|---|
| Handler status codes and JSON | oneshot against the router |
| Timeouts, retries, intervals | start_paused = true, assert virtual elapsed time |
| Graceful shutdown | cancel the token, tracker.wait() under a timeout |
| Backpressure | bounded channel try_send returns Full |
Futures stay Send | assert_send(&fut) compile-time check |
| Middleware on a real socket | bind 127.0.0.1:0, request, cancel, await the server task |
| External services | fakes behind traits; wiremock for HTTP; #[sqlx::test] for SQL |
rust-borrow-checker-error-explainer
Explain and fix Rust borrow-checker, ownership, move and lifetime compiler errors: E0382 use of moved value, E0499 cannot borrow as mutable more than once, E0502 also borrowed as immutable, E0505, E0506, E0503, E0507 cannot move out of index/Rc/reference, E0515 return reference to local, E0597 does not live long enough, E0716 temporary value dropped while borrowed, E0373 closure may outlive (spawn needs move), E0521 borrowed data escapes, E0384, E0596, E0594, E0381, E0713, 'lifetime may not live long enough', RefCell 'already borrowed: BorrowMutError' panics. Ranks fixes from restructuring (split borrows, entry API, split_at_mut, get_disjoint_mut, mem::take, Option::take, indices, thread::scope) down to clone and Rc<RefCell>/Arc<Mutex>; never unsafe. Covers NLL limits (get-or-insert), iterator invalidation, closure captures, self-referential structs. Ships std-only Rust triage tools.
rust-cargo-workspace-designer
Design, review and fix Cargo workspaces: when to split a crate (compile time, boundaries, publishing, proc-macros), crates/* vs apps/ + crates/ layouts, virtual manifest vs root package, members/exclude/default-members, resolver 2 vs 3 (MSRV-aware, edition 2024), [workspace.package] and [workspace.dependencies] inheritance, [workspace.lints], root-only profiles (lto, codegen-units), additive features with dep: and weak ?/ syntax, feature unification pitfalls, internal path + version deps, publish = false, Cargo.lock and MSRV policy, xtask, CI with --locked, nextest, rust-cache, sccache, cargo-hack/deny/machete/udeps/semver-checks, and cargo build --timings. Ships a workspace auditor and crate-graph tool.
rust-error-handling-thiserror-anyhow-designer
Design and review Rust error handling with thiserror 2 and anyhow 1: library vs application split, typed error enums with #[from], #[source], #[error(transparent)] and #[non_exhaustive], ErrorKind patterns, context via .context()/.with_context(), bail!/ensure!, source() chains, {:#} and {:?} reports, RUST_BACKTRACE/RUST_LIB_BACKTRACE, Box<dyn Error + Send + Sync>, main -> anyhow::Result or ExitCode, unwrap/expect/panic policy, Option to Result, collecting iterators of Results, axum IntoResponse mapping, retryable errors, tracing, async Send bounds, testing with matches! and downcast_ref, no_std core::error::Error, and snafu, eyre/color-eyre, miette, error-stack.
rust-lifetime-annotation-advisor
Decide when Rust lifetime annotations are needed and which ones: the three elision rules, structs holding references (struct Parser<'a>), owned vs borrowed (String, &str, Cow<'a, str>, Arc<str>), one vs several lifetimes, 'a: 'b bounds, T: 'static misconceptions, Box<dyn Trait + 'a> default object lifetimes, impl Trait + '_ and edition 2024 capture rules with use<..>, variance and PhantomData, HRTB for<'a> Fn(&'a T), GATs and lending iterators, async fn and tokio::spawn 'static, self-referential structs (ouroboros, self_cell, indices, arenas), Rc/Arc to escape lifetime soup. Ships std-only Rust checkers for redundant, missing, unused and over-restrictive lifetimes.
rust-trait-design-reviewer
Review and design Rust traits and generic APIs: generics vs impl Trait vs dyn Trait (static vs dynamic dispatch), dyn compatibility / object safety (generic methods, Self returns, where Self: Sized, error[E0038] not dyn compatible / cannot be made into an object), associated types vs generic parameters, blanket impls, coherence and orphan rule (E0117, E0119, E0210), newtype, extension, sealed and marker traits, supertraits and trait upcasting (1.86), async fn in traits (1.75) and the Send bound problem (trait_variant, async-trait), RPITIT, GATs, From/Into/TryFrom/AsRef/Borrow, std trait checklist (Debug, Clone, PartialEq, Hash, Default, Display, Error, Send/Sync, serde feature), #[must_use], #[non_exhaustive], semver hazards, Deref abuse, builders, typestate, PhantomData, Box<dyn Error + Send + Sync>, Fn traits, mockall.
rust-unsafe-block-auditor
Audit unsafe Rust for soundness: inventory every unsafe block, unsafe fn, unsafe impl/trait, extern block and unsafe attribute, check // SAFETY: comments and # Safety docs, and classify each as sound, needs-justification or likely UB, proposing safe alternatives first. Covers the five unsafe superpowers, aliasing (Stacked/Tree Borrows), invalid values, MaybeUninit vs mem::zeroed/uninitialized, transmute, slice::from_raw_parts, Vec::set_len/from_raw_parts, get_unchecked, CString::as_ptr dangling, unsafe impl Send/Sync, Pin projection, FFI (extern "C", repr(C), C-unwind, Box::into_raw/from_raw), strict provenance, static mut and &raw, edition 2024 (unsafe extern, #[unsafe(no_mangle)], unsafe_op_in_unsafe_fn, env::set_var), Miri, sanitizers, cargo-fuzz, loom, cargo-geiger and clippy safety lints.