Rust testing patterns for async web services
General rules
- Always return
Result<(), Box<dyn Error>>from tests — use?everywhere, neverunwrap() - Wrap every async assertion in
tokio::time::timeout(Duration, future).await?as a safety net against hanging tests - Use configurable intervals via
Statefor 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()intimeout()— 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 parsedEventstructs- Each
Eventhas.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
- Axum WebSocket patterns — the patterns these tests verify
- Axum Dependency Injection — State injection for test configuration
- Async — tokio test runtime