tokio-test: Testing Async Rust Without a Full Runtime

tokio-test: Testing Async Rust Without a Full Runtime

tokio-test gives you utilities for testing async Rust code in isolation — mocking I/O streams, controlling time, and asserting on async behavior without requiring a real network or file system. It's the right tool when you want fast, deterministic tests for code that depends on Tokio primitives.

Key Takeaways

  • tokio-test provides mock I/O types that you can control from tests
  • assert_pending! and assert_ready! macros test Future state without await
  • task::spawn returns a handle that drives the future step-by-step
  • io::Builder lets you script expected reads and writes
  • Use tokio::time::pause() to control time in tests without sleeping

What tokio-test solves

Async Rust tests run inside a Tokio runtime, which is fine for most cases. But when your code under test interacts with AsyncRead, AsyncWrite, or other I/O primitives, you need a way to control those interactions from the test side.

Testing a TCP client against a real server is slow and flaky. Testing it against a mock that you control is fast and deterministic. tokio-test provides that mock layer.

Installation

[dev-dependencies]
tokio-test = "0.4"
tokio = { version = "1", features = ["full", "test-util"] }

The test-util feature in Tokio itself is needed for time control features like tokio::time::pause().

The core primitives

io::Builder — mock byte streams

io::Builder lets you script a sequence of read/write operations that your code will see:

use tokio_test::io::Builder;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::test]
async fn test_reads_greeting() {
    let mock = Builder::new()
        .read(b"Hello, world!")
        .build();

    let mut buf = vec![0u8; 13];
    let mut mock = mock;
    mock.read_exact(&mut buf).await.unwrap();

    assert_eq!(&buf, b"Hello, world!");
}

You can chain multiple reads and writes to simulate a full protocol exchange:

let mock = Builder::new()
    .read(b"PING\r\n")
    .write(b"PONG\r\n")
    .read(b"QUIT\r\n")
    .write(b"+OK\r\n")
    .build();

If your code writes data in a different order or writes unexpected bytes, the mock panics with a clear message — making test failures easy to diagnose.

Scripting errors

Real I/O fails. Your code needs to handle it:

use std::io;

let mock = Builder::new()
    .read(b"partial data")
    .read_error(io::Error::new(io::ErrorKind::ConnectionReset, "reset"))
    .build();

This lets you test error-handling paths that are difficult to trigger against a real server.

Testing Future state directly

Sometimes you want to check whether a Future is pending or ready without awaiting it. tokio-test provides macros for this:

use tokio_test::{assert_pending, assert_ready, assert_ready_ok, assert_ready_err};
use std::future::Future;

#[tokio::test]
async fn test_channel_pending_then_ready() {
    let (tx, mut rx) = tokio::sync::mpsc::channel::<u32>(1);

    let mut recv_future = Box::pin(rx.recv());
    
    // Nothing sent yet — should be pending
    assert_pending!(recv_future.as_mut());

    // Send a value
    tx.send(42).await.unwrap();

    // Now it should be ready
    let value = assert_ready!(recv_future.as_mut());
    assert_eq!(value, Some(42));
}

This is useful for testing channel consumers, stream processors, and any code where the timing of readiness matters.

task::spawn — step-by-step execution

tokio_test::task::spawn wraps a future in a test harness that lets you drive it manually and inspect its state:

use tokio_test::task;

#[tokio::test]
async fn test_future_wakes_correctly() {
    let (tx, rx) = tokio::sync::oneshot::channel::<i32>();
    let mut task = task::spawn(rx);

    // Future is pending
    assert!(task.poll().is_pending());
    assert!(!task.is_woken());

    // Sending a value wakes the task
    tx.send(99).unwrap();
    assert!(task.is_woken());

    // Polling again yields the value
    let result = task.poll();
    assert!(result.is_ready());
}

The is_woken() check verifies that your waker logic is correct — important when writing custom Future implementations or async primitives.

Controlling time

Slow tests that depend on timeouts are a common pain point. The test-util feature in Tokio provides tokio::time::pause() to freeze time and advance it manually:

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

#[tokio::test]
async fn test_timeout_fires() {
    time::pause();

    let sleep = tokio::time::sleep(Duration::from_secs(30));
    tokio::pin!(sleep);

    // Sleep is pending — time hasn't advanced
    assert!(!sleep.as_mut().is_elapsed());

    // Advance time by 31 seconds
    time::advance(Duration::from_secs(31)).await;

    // Now it's elapsed
    assert!(sleep.is_elapsed());
}

Combined with tokio::time::timeout, this lets you test timeout-based retry logic, expiry, and scheduling without waiting real wall-clock time.

Testing a complete async component

Here's a realistic example — testing an async Redis-like client that speaks a simple text protocol:

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio_test::io::Builder;

struct SimpleClient<S> {
    stream: BufReader<S>,
}

impl<S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin> SimpleClient<S> {
    fn new(stream: S) -> Self {
        Self { stream: BufReader::new(stream) }
    }

    async fn get(&mut self, key: &str) -> anyhow::Result<String> {
        let cmd = format!("GET {}\r\n", key);
        self.stream.get_mut().write_all(cmd.as_bytes()).await?;

        let mut response = String::new();
        self.stream.read_line(&mut response).await?;
        Ok(response.trim().to_string())
    }
}

#[tokio::test]
async fn test_get_returns_value() {
    let mock = Builder::new()
        .write(b"GET username\r\n")
        .read(b"alice\r\n")
        .build();

    let mut client = SimpleClient::new(mock);
    let value = client.get("username").await.unwrap();

    assert_eq!(value, "alice");
}

#[tokio::test]
async fn test_get_handles_error() {
    let mock = Builder::new()
        .write(b"GET missing\r\n")
        .read(b"ERR key not found\r\n")
        .build();

    let mut client = SimpleClient::new(mock);
    let value = client.get("missing").await.unwrap();

    assert_eq!(value, "ERR key not found");
}

The mock enforces exact byte-level protocol adherence. If your client sends the wrong command format, the test fails immediately with a clear message.

When to use tokio-test vs full integration tests

Scenario Use
Testing protocol encoding/decoding tokio-test mock I/O
Testing timeout and retry logic tokio-test + time::pause()
Testing a real database connection Integration test with testcontainers
Testing custom Future implementations tokio-test task::spawn
Testing HTTP handlers axum-test or reqwest against test server

tokio-test shines for the IO-heavy protocol layer. For higher-level HTTP or database interactions, integration test libraries give you more realistic coverage.

Common pitfalls

Mock panics on unexpected writes: If your code writes bytes the mock didn't expect, it panics. This is intentional — it means your protocol implementation deviated from the spec. Read the panic message carefully, it shows exactly what was expected vs what arrived.

Forgetting test-util feature: tokio::time::pause() requires features = ["test-util"] in tokio's dev-dependencies. Without it, the function doesn't exist.

Mixing real and mock I/O: The mock type only implements AsyncRead and AsyncWrite. If your code also requires AsyncSeek, you'll need a different approach (like using a Cursor).

Summary

tokio-test fills the gap between pure unit tests and full integration tests for async Rust. The mock I/O builder gives you precise control over what bytes your code sees and sends, the assert_pending!/assert_ready! macros let you inspect Future state, and tokio::time::pause() makes time-dependent tests instantaneous. Together they give you a solid foundation for testing Tokio-based code without any external dependencies.

Read more