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.