Rust testing patterns for async web services

General rules

  • Always return Result<(), Box<dyn Error>> from tests — use ? everywhere, never unwrap()
  • Wrap every async assertion in tokio::time::timeout(Duration, future).await? as a safety net against hanging tests
  • Use configurable intervals via State for test speed (10ms instead of 1s production intervals)

HTTP unit tests: axum-test

In-process, no real TCP. Good for status codes, headers, JSON body assertions:

use axum_test::TestServer;
 
#[tokio::test]
async fn test_health_returns_ok() -> Result<(), Box<dyn Error>> {
    let server = TestServer::new(router());
    let res = server.get("/health").await;
    res.assert_status_ok();
    Ok(())
}
  • Builds the router directly — no port binding
  • Fast, deterministic
  • Best for: route wiring, extractor behavior, response shapes

HTTP integration tests: reqwest

Real HTTP over TCP. Use port 0 for OS-assigned ephemeral port:

async fn spawn_server() -> String {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, router()).await.unwrap();
    });
    format!("http://{addr}")
}
 
#[tokio::test]
async fn test_api_returns_json() -> Result<(), Box<dyn Error>> {
    let base = spawn_server().await;
    let res = reqwest::get(format!("{base}/api/data")).await?;
    assert_eq!(res.status(), 200);
    Ok(())
}
  • Best for: middleware chains, TLS, real network behavior

WebSocket tests: tokio-tungstenite

use tokio_tungstenite::connect_async;
use futures_util::{SinkExt, StreamExt};
 
#[tokio::test]
async fn test_ws_echo() -> Result<(), Box<dyn Error>> {
    let base = spawn_server().await;
    let url = base.replace("http", "ws") + "/ws";
    let (mut ws, _) = connect_async(url).await?;
 
    ws.send(TungsteniteMessage::Text("hello".into())).await?;
 
    let resp = timeout(Duration::from_secs(2), ws.next())
        .await?
        .ok_or("stream ended")?
        .map_err(|e| e.to_string())?;
 
    assert_eq!(resp, TungsteniteMessage::Text("hello".into()));
    Ok(())
}
  • connect_async(url) returns (WebSocketStream, Response)
  • SinkExt::send() to write, StreamExt::next() to read
  • Always wrap .next() in timeout() — a missing message hangs forever

SSE tests: eventsource-stream

use eventsource_stream::Eventsource;
 
#[tokio::test]
async fn test_sse_stream() -> Result<(), Box<dyn Error>> {
    let base = spawn_server().await;
    let res = reqwest::get(format!("{base}/sse")).await?;
 
    let mut stream = res.bytes_stream().eventsource();
 
    let event = timeout(Duration::from_secs(2), stream.next())
        .await?
        .ok_or("no event")??;
 
    assert_eq!(event.event, "message");
    Ok(())
}
  • .eventsource() is an extension method on any byte stream — yields parsed Event structs
  • Each Event has .event (type), .data (payload), .id

Configurable timing

Inject intervals through state so tests run fast:

struct AppState {
    tick_interval: Duration,
}
 
// Production
AppState { tick_interval: Duration::from_secs(1) }
 
// Tests
AppState { tick_interval: Duration::from_millis(10) }

See also