Testing async code requires a runtime
You write an async function to fetch a user profile. It works perfectly when you run the app. You spin up a test to verify the logic. The compiler rejects you with a wall of text about futures and types. You try wrapping the call in block_on. The test panics because you're already inside a runtime, or the error is worse: you're blocking the only thread that could drive the future to completion. Testing async code in Rust feels like trying to start a car while standing inside the engine bay. The tooling is there, but the mental model shifts from "run the function" to "schedule the task and wait for it."
Futures are lazy promises
An async function doesn't do the work immediately. It returns a future. A future is a lazy recipe. It describes the work, but it won't execute until something polls it. Think of a future like a ticket at a busy deli. You hand in your order and get a numbered slip. The slip isn't the sandwich. The slip is a promise that the sandwich will appear eventually. The runtime is the deli counter. It holds the tickets, calls the numbers, and hands out the food.
Standard Rust tests run in a synchronous world. They expect a value, not a ticket. If you pass a future to a standard test, the test sees the ticket, checks if it's a sandwich, finds it isn't, and fails. You need a runtime inside the test to play the role of the deli counter. The runtime polls the future, advances the work, and returns the result. Without a runtime, your future is just a piece of data sitting in memory.
Minimal example
The #[tokio::test] attribute bridges the gap. It tells the test harness to spin up a runtime and drive your async function to completion.
use tokio::test;
/// Returns a fixed value after a simulated delay.
async fn get_value() -> i32 {
42
}
/// Standard tests expect a synchronous function returning a Result or ().
/// An async fn returns a Future, which causes a type mismatch.
#[test]
fn test_sync_fails() {
// This line causes E0308: mismatched types.
// The compiler expects (), but get_value() returns a Future.
// let val = get_value();
// assert_eq!(val, 42);
}
/// The #[tokio::test] attribute wraps the function in a runtime.
/// It transforms the async fn into a sync fn that drives the future to completion.
#[tokio::test]
async fn test_async_works() {
// The runtime polls this future until it yields a value.
let val = get_value().await;
assert_eq!(val, 42);
}
The attribute hides the complexity. You write async code; the macro handles the plumbing.
What the macro generates
The #[tokio::test] attribute is a macro. It rewrites your test function behind the scenes. It generates a new synchronous function with the same name. Inside that wrapper, it creates a Tokio runtime. It calls your async function to get the future. It passes that future to the runtime's block_on method. The runtime drives the future to completion, collects the result, and returns it. If the future panics, the wrapper catches it and reports it as a test failure.
The generated code looks conceptually like this:
#[test]
fn test_async_works() {
// The macro creates a runtime scoped to the test.
let rt = tokio::runtime::Runtime::new().unwrap();
// It calls the original async function to get the future.
let future = async {
let val = get_value().await;
assert_eq!(val, 42);
};
// It blocks the current thread until the future completes.
rt.block_on(future);
}
You don't write this boilerplate. The attribute injects it. The test harness sees a normal synchronous function. The runtime lives and dies within the test scope. Resources are cleaned up when the test ends.
Realistic example: concurrency and channels
Tests often need to verify concurrent behavior. You might spawn a background worker and send it messages. The test must coordinate with the worker to ensure correctness.
use tokio::sync::mpsc;
/// Processes messages from a channel until it closes.
/// Returns a list of processed strings.
async fn process_messages(mut rx: mpsc::Receiver<String>) -> Vec<String> {
let mut results = Vec::new();
// recv() is async because it waits for data.
// The loop suspends when no data is available.
while let Some(msg) = rx.recv().await {
results.push(format!("Processed: {}", msg));
}
results
}
#[tokio::test]
async fn test_worker_processes_all() {
// Channel size 10 allows buffering.
// The receiver is moved into the worker task.
let (tx, rx) = mpsc::channel(10);
// spawn creates a new task on the runtime.
// The worker runs concurrently with the test code.
let handle = tokio::spawn(process_messages(rx));
// Send messages. await yields control back to the runtime.
// This allows the worker to potentially receive if scheduled.
tx.send("hello".to_string()).await.unwrap();
tx.send("world".to_string()).await.unwrap();
// Dropping the sender signals the receiver to stop.
// Without this, the worker would hang forever.
drop(tx);
// handle.await waits for the spawned task to complete.
// unwrap() propagates panics from the worker to the test.
let results = handle.await.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0], "Processed: hello");
}
Spawn tasks, drop senders, await handles. Control the lifecycle explicitly. The test drives the worker by sending data and then closing the channel. The worker finishes when the channel closes. The test awaits the handle to collect the result. This pattern ensures the test waits for all work to complete before asserting.
Speeding up tests with virtual time
Async code often sleeps or waits for timeouts. Real sleep makes tests slow. Tokio provides a virtual clock for tests. Enable the test-util feature in Cargo.toml to access this functionality. The runtime supports pausing time. When paused, sleeps advance the clock instantly instead of waiting real time.
#[tokio::test]
async fn test_timeout_without_waiting() {
// Enable virtual time for this runtime.
// This makes sleeps advance the clock instantly.
tokio::time::pause();
let start = std::time::Instant::now();
// This sleep takes 0 real time.
// The runtime advances the virtual clock by 10 seconds.
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let elapsed = start.elapsed();
// Elapsed is near zero, not 10 seconds.
assert!(elapsed.as_secs() < 1);
}
Community convention is to add tokio = { version = "1", features = ["full", "test-util"] } to dev-dependencies. The test-util feature unlocks pause() and other testing helpers. Use virtual time whenever your code depends on delays or timeouts. Tests run in milliseconds instead of minutes.
Pitfalls and compiler errors
If you forget the #[tokio::test] attribute, the compiler rejects the code with E0308 (mismatched types). The test framework expects a function returning () or Result<(), E>. An async function returns a future. The types don't match. Add the attribute or wrap the call in a runtime manually.
If you try to call tokio::runtime::Runtime::new().block_on(...) inside a test that already uses #[tokio::test], the program panics. Tokio detects a re-entrant runtime. You cannot start a runtime from inside a runtime on the same thread. The error message mentions "Cannot start a runtime from within a runtime." Remove the manual runtime creation. The attribute handles it.
If you call an async function without .await, the future is created but never polled. The test finishes immediately. The assertion checks a future, not the result. The test might pass by accident if you assert on the future type, or fail with a type error. Always await async calls in tests.
Respect the runtime boundaries. Nesting runtimes is a trap. Await everything.
Decision matrix
Use #[tokio::test] when you need to test async functions and want the runtime managed automatically. Use #[tokio::test(flavor = "current_thread")] when your test relies on thread-local state or you need deterministic execution order without multi-threading overhead. Use #[test] with a manual Runtime::new().block_on(...) only when you need to configure the runtime specifically for that test, such as setting a custom time source or thread count. Use #[serial_test::serial] alongside #[tokio::test] when tests share mutable global state or file system resources and must run one at a time. Use wiremock or mockito when your async code makes HTTP requests and you need to intercept them without hitting a real server. Use tokio::time::pause() when your code contains sleeps or timeouts and you want the test to complete instantly.
Pick the tool that matches your constraint. Default to #[tokio::test]. Reach for configuration only when the default fails.