Rust Async Testing with tokio-test: Patterns for Complex Async Code

Rust Async Testing with tokio-test: Patterns for Complex Async Code

Testing async Rust code with #[tokio::test] handles the basics — but complex async patterns (state machines, channels, time-dependent logic, concurrent operations) need more. This guide covers advanced async testing patterns beyond the basics.

The Foundation: #[tokio::test]

The baseline for async Rust tests:

[dev-dependencies]
tokio = { version = "1", features = ["full", "test-util"] }
tokio-test = "0.4"
#[tokio::test]
async fn test_basic_async() {
    let result = my_async_function().await;
    assert_eq!(result, 42);
}

Testing Time-Dependent Code with tokio::time::pause

Real-time sleeps make tests slow and flaky. Use tokio::time::pause() to control time:

use tokio::time::{sleep, Duration, pause, advance};

// The function being tested uses tokio sleep
pub async fn rate_limited_operation() -> Result<(), String> {
    for attempt in 0..3 {
        match try_operation().await {
            Ok(result) => return Ok(result),
            Err(_) if attempt < 2 => {
                let delay = Duration::from_secs(2u64.pow(attempt));  // Exponential backoff
                sleep(delay).await;
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}
#[tokio::test]
async fn test_rate_limited_operation_retries_with_backoff() {
    // Pause tokio's clock — time doesn't advance automatically
    pause();
    
    let operation_calls = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
    let calls_clone = operation_calls.clone();
    
    let handle = tokio::spawn(async move {
        rate_limited_operation_with_counter(&calls_clone).await
    });
    
    // First attempt fails immediately
    assert_eq!(operation_calls.load(Ordering::SeqCst), 1);
    
    // Advance 2 seconds — first retry delay
    advance(Duration::from_secs(2)).await;
    assert_eq!(operation_calls.load(Ordering::SeqCst), 2);
    
    // Advance 4 more seconds — second retry delay
    advance(Duration::from_secs(4)).await;
    assert_eq!(operation_calls.load(Ordering::SeqCst), 3);
    
    // Total elapsed: 6 seconds in real time: 0 seconds
    handle.await.unwrap().unwrap();
}

Testing Channels

Tokio channels are common in async code. Test them directly:

use tokio::sync::{mpsc, oneshot};

// Producer-consumer pattern
pub async fn process_items(
    rx: &mut mpsc::Receiver<String>,
    processed: &mut Vec<String>,
) {
    while let Some(item) = rx.recv().await {
        processed.push(item.to_uppercase());
    }
}

#[tokio::test]
async fn test_items_processed_in_order() {
    let (tx, mut rx) = mpsc::channel(10);
    
    tx.send("hello".to_string()).await.unwrap();
    tx.send("world".to_string()).await.unwrap();
    drop(tx);  // Close the channel
    
    let mut processed = Vec::new();
    process_items(&mut rx, &mut processed).await;
    
    assert_eq!(processed, vec!["HELLO", "WORLD"]);
}

#[tokio::test]
async fn test_channel_backpressure() {
    let (tx, mut rx) = mpsc::channel(2);  // Buffer of 2
    
    tx.send("first".to_string()).await.unwrap();
    tx.send("second".to_string()).await.unwrap();
    
    // Channel is full — this should fail immediately
    let result = tx.try_send("third".to_string());
    assert!(result.is_err());
    
    // Drain one item
    rx.recv().await;
    
    // Now there's space
    tx.try_send("third".to_string()).unwrap();
}

#[tokio::test]
async fn test_oneshot_channel() {
    let (tx, rx) = oneshot::channel::<String>();
    
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(10)).await;
        tx.send("response".to_string()).unwrap();
    });
    
    let response = rx.await.unwrap();
    assert_eq!(response, "response");
}

Testing Timeouts

use tokio::time::{timeout, Duration};

pub async fn fetch_with_timeout(url: &str, secs: u64) -> Result<String, String> {
    timeout(Duration::from_secs(secs), fetch(url))
        .await
        .map_err(|_| format!("Timeout after {}s", secs))?
        .map_err(|e| e.to_string())
}

#[tokio::test]
async fn test_timeout_returns_error_when_exceeded() {
    pause();
    
    // Start a slow operation
    let handle = tokio::spawn(async {
        fetch_with_timeout("http://slow-server.example", 5).await
    });
    
    // Advance past the timeout
    advance(Duration::from_secs(6)).await;
    
    let result = handle.await.unwrap();
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("Timeout"));
}

#[tokio::test]
async fn test_completes_before_timeout() {
    let result = fetch_with_timeout("http://fast-server.example", 30).await;
    assert!(result.is_ok());
}

Testing Concurrent Operations

use tokio::task::JoinSet;

pub async fn process_batch(items: Vec<String>) -> Vec<Result<String, String>> {
    let mut set = JoinSet::new();
    
    for item in items {
        set.spawn(async move { process_single(&item).await });
    }
    
    let mut results = Vec::new();
    while let Some(res) = set.join_next().await {
        results.push(res.unwrap());
    }
    results
}

#[tokio::test]
async fn test_batch_processes_all_items() {
    let items: Vec<String> = (0..10).map(|i| format!("item-{}", i)).collect();
    
    let results = process_batch(items).await;
    
    assert_eq!(results.len(), 10);
    assert!(results.iter().all(|r| r.is_ok()));
}

#[tokio::test]
async fn test_batch_handles_partial_failures() {
    let items = vec![
        "good-item".to_string(),
        "bad-item".to_string(),  // Causes error
        "good-item-2".to_string(),
    ];
    
    let results = process_batch(items).await;
    
    assert_eq!(results.len(), 3);
    let success_count = results.iter().filter(|r| r.is_ok()).count();
    assert_eq!(success_count, 2);
}

Testing Async State Machines

State machines are common in async code. Test state transitions:

#[derive(Debug, PartialEq, Clone)]
pub enum ConnectionState {
    Disconnected,
    Connecting,
    Connected,
    Error(String),
}

pub struct Connection {
    state: ConnectionState,
    tx: mpsc::Sender<ConnectionState>,
}

impl Connection {
    pub async fn connect(&mut self) -> Result<(), String> {
        self.set_state(ConnectionState::Connecting).await;
        
        match tcp_connect().await {
            Ok(_) => {
                self.set_state(ConnectionState::Connected).await;
                Ok(())
            }
            Err(e) => {
                let err = e.to_string();
                self.set_state(ConnectionState::Error(err.clone())).await;
                Err(err)
            }
        }
    }
}

#[tokio::test]
async fn test_connection_state_transitions_on_success() {
    let (tx, mut rx) = mpsc::channel(10);
    let mut conn = Connection::new_test(tx);
    
    let result = conn.connect().await;
    assert!(result.is_ok());
    
    // Verify state transitions in order
    let states: Vec<ConnectionState> = {
        let mut v = Vec::new();
        while let Ok(s) = rx.try_recv() {
            v.push(s);
        }
        v
    };
    
    assert_eq!(states, vec![
        ConnectionState::Connecting,
        ConnectionState::Connected,
    ]);
}

#[tokio::test]
async fn test_connection_state_transitions_on_failure() {
    let (tx, mut rx) = mpsc::channel(10);
    let mut conn = Connection::new_test_with_failing_tcp(tx);
    
    let result = conn.connect().await;
    assert!(result.is_err());
    
    let states: Vec<ConnectionState> = {
        let mut v = Vec::new();
        while let Ok(s) = rx.try_recv() { v.push(s); }
        v
    };
    
    assert!(matches!(states.last(), Some(ConnectionState::Error(_))));
}

Using tokio_test::io for Stream Testing

tokio_test::io::Builder lets you create mock async I/O:

use tokio_test::io::Builder;

#[tokio::test]
async fn test_reads_data_correctly() {
    let mock = Builder::new()
        .read(b"Hello")
        .read(b", World!")
        .build();
    
    let mut reader = tokio::io::BufReader::new(mock);
    
    let mut buf = String::new();
    use tokio::io::AsyncReadExt;
    reader.read_to_string(&mut buf).await.unwrap();
    
    assert_eq!(buf, "Hello, World!");
}

#[tokio::test]
async fn test_handles_connection_reset() {
    use std::io::ErrorKind;
    
    let mock = Builder::new()
        .read(b"partial data")
        .read_error(std::io::Error::new(ErrorKind::ConnectionReset, "reset"))
        .build();
    
    let result = read_until_complete(mock).await;
    assert!(result.is_err());
}

Testing Retry Logic

pub async fn fetch_with_retry(url: &str, max_attempts: u32) -> Result<String, String> {
    let mut last_error = String::new();
    
    for attempt in 0..max_attempts {
        match fetch(url).await {
            Ok(data) => return Ok(data),
            Err(e) => {
                last_error = e;
                if attempt < max_attempts - 1 {
                    sleep(Duration::from_millis(100 * 2u64.pow(attempt))).await;
                }
            }
        }
    }
    
    Err(format!("Failed after {} attempts: {}", max_attempts, last_error))
}

#[tokio::test]
async fn test_retry_succeeds_on_third_attempt() {
    let attempt_count = Arc::new(AtomicU32::new(0));
    let count = attempt_count.clone();
    
    // Mock that fails twice then succeeds
    let result = fetch_with_retry_and_counter(&count, 3).await;
    
    assert!(result.is_ok());
    assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
}

#[tokio::test]
async fn test_retry_gives_up_after_max_attempts() {
    let attempt_count = Arc::new(AtomicU32::new(0));
    let count = attempt_count.clone();
    
    let result = fetch_always_failing_with_counter(&count, 3).await;
    
    assert!(result.is_err());
    assert_eq!(attempt_count.load(Ordering::SeqCst), 3);
    assert!(result.unwrap_err().contains("3 attempts"));
}

CI Configuration

- name: Run async tests
  run: cargo test -- --test-threads=4
  env:
    TOKIO_WORKER_THREADS: "1"  # Single worker for deterministic ordering in tests

Conclusion

Advanced async testing in Rust requires three tools working together: #[tokio::test] for the test runtime, tokio::time::pause()/advance() for deterministic time control, and mock I/O from tokio_test for stream testing. With these, you can test retry logic without sleeping, channel backpressure without race conditions, and state machines with explicit transition verification — all running in milliseconds rather than seconds.

Start now free