Skip to content
v0.1.1latest release34 skills2 categories4 sub-categories79 tested scripts157 reference docsContact us

Testing Tokio Services

Reference for rust-tokio-async-service-builder · 273 lines

Programming / Rustv1.0.0Updated OCT 21 20267 references8 scripts

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

AttributeRuntimeUse for
#[tokio::test]current_threadmost tests: deterministic, no Send requirement on locals
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]multi_threadcode using block_in_place, real parallelism, races
#[tokio::test(start_paused = true)]current_thread, paused clocktimeouts, 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.

rust
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.

rust
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:

rust
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

rust
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

rust
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:

rust
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).

rust
#[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 (from DATABASE_URL) and runs migrations; the test receives a PgPool. Keep pure logic out of the database layer so most tests do not need it.
  • HTTP upstreams: wiremock starts a local mock server per test (Mock::given(method("GET")).respond_with(..)); point the client's base URL at mock_server.uri().
  • Never call real third-party services from unit tests; use contract tests in a separate job.

Checklist

BehaviourTest
Handler status codes and JSONoneshot against the router
Timeouts, retries, intervalsstart_paused = true, assert virtual elapsed time
Graceful shutdowncancel the token, tracker.wait() under a timeout
Backpressurebounded channel try_send returns Full
Futures stay Sendassert_send(&fut) compile-time check
Middleware on a real socketbind 127.0.0.1:0, request, cancel, await the server task
External servicesfakes behind traits; wiremock for HTTP; #[sqlx::test] for SQL
More in Rust

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.