The synchronous trap
You write an async function that fetches data or processes a stream. You want to test it. You add #[test] to your test function, mark it async, and run cargo test. The compiler rejects the signature. #[test] expects a function that returns (). Your function returns a Future.
The test harness is synchronous. It calls your function, receives a future, and has no idea how to drive it. In some configurations, the future gets dropped immediately. The test reports success. Your code never ran.
That's the trap. Synchronous test runners cannot poll asynchronous code. You need a runtime to execute the future to completion.
The runtime wrapper
#[tokio::test] is a macro that solves this. It rewrites your async function into a synchronous wrapper. The wrapper creates a Tokio runtime, spawns your async code, and blocks the thread until the future resolves.
Think of the runtime as an event loop. Your async function is a task waiting for I/O, timers, or other tasks. The runtime wakes up, checks what's ready, and makes progress. Without the runtime, the future sits idle. The macro provides the engine.
Add tokio to your dev-dependencies. This keeps the dependency out of your production binary. Tests are development tools.
Convention: Use features = ["rt-multi-thread"] for tests. The full feature includes everything, including macros and I/O types, which bloats compile time. rt-multi-thread gives you the runtime and multi-threading support. That's usually enough for testing.
#[tokio::test]
async fn test_basic_async() {
// #[tokio::test] sets up a runtime and blocks until this future completes.
// The macro handles the plumbing; you write the logic.
let result = 2 + 2;
assert_eq!(result, 4);
}
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread"] }
The attribute does the heavy lifting. You focus on the assertions.
How the macro expands
The macro generates code that looks roughly like this:
#[test]
fn test_basic_async() {
// Create a new runtime for this test.
let rt = tokio::runtime::Runtime::new().unwrap();
// Block the current thread until the async function finishes.
rt.block_on(async {
let result = 2 + 2;
assert_eq!(result, 4);
});
}
The macro creates a Runtime instance. It calls block_on, which polls the future until it returns Poll::Ready. The test thread blocks during this time. When the future completes, block_on returns, and the test function exits.
Each #[tokio::test] gets its own runtime. This isolation prevents tests from interfering with each other. One test's spawned tasks don't leak into another test's runtime.
The runtime is scoped to the test function. When the test ends, the runtime drops. Any tasks still running get cancelled. Tokio handles the cleanup.
Trust the isolation. Each test runs in its own bubble.
Realistic concurrency
Async tests shine when you test concurrent behavior. You can spawn tasks, use channels, and verify that your code handles parallelism correctly.
use tokio::sync::mpsc;
#[tokio::test]
async fn test_channel_communication() {
// Create a channel for sending messages between tasks.
let (tx, mut rx) = mpsc::channel(10);
// Spawn a task that sends a message.
let sender = tokio::spawn(async move {
tx.send("hello").await.unwrap();
});
// Await the message in the test task.
let msg = rx.recv().await.unwrap();
assert_eq!(msg, "hello");
// Ensure the sender task completed successfully.
sender.await.unwrap();
}
The spawn call schedules the async block on the runtime's thread pool. The await on tx.send yields control back to the runtime. The runtime polls the receiver, sees the message, and delivers it.
Convention: JoinHandle::await returns a Result. If the spawned task panics, you get Err. Always handle the result. Use unwrap() if a panic in the task should fail the test. Use expect() with a message for better error reporting.
Spawn tasks freely. The runtime manages the lifecycle.
Controlling time
Tests with timeouts or delays can be slow. Waiting ten seconds for a timeout test kills your feedback loop. Tokio provides time mocking to fix this.
Call tokio::time::pause() at the start of your test. This mocks the clock. Calls to tokio::time::sleep advance the virtual time immediately instead of waiting for real time to pass.
#[tokio::test]
async fn test_timeout_with_mocked_time() {
// pause() mocks the clock. Sleeps advance instantly.
tokio::time::pause();
let start = tokio::time::Instant::now();
// This sleep completes instantly in mocked time.
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let elapsed = start.elapsed();
// The virtual time advanced by 10 seconds.
assert!(elapsed.as_secs() >= 10);
}
The Instant::now() call returns the current virtual time. elapsed() measures the difference in virtual time. The test runs in milliseconds, not seconds.
Convention: pause() affects the entire runtime. If you run tests in parallel, one test's pause() can affect others. Use #[tokio::test(flavor = "current_thread")] for time-mocked tests to ensure determinism.
Mock time to kill flaky timeouts. Your tests run instantly.
Concurrency flavors
Tokio runtimes come in flavors. The default for #[tokio::test] is multi_thread. You can override this with the flavor parameter.
current_thread runs everything on a single thread. Execution is deterministic. Tasks run in the order you spawn them, interleaved only by await points. This flavor is ideal for testing logic that uses RefCell or Rc, which aren't thread-safe.
multi_thread uses a thread pool. Execution is non-deterministic. Tasks can run on different threads, and scheduling varies between runs. This flavor is ideal for stress testing and verifying that your code works correctly under concurrent scheduling.
// Deterministic single-threaded test.
#[tokio::test(flavor = "current_thread")]
async fn test_refcell_usage() {
let data = std::cell::RefCell::new(vec![1, 2, 3]);
// RefCell works here because everything runs on one thread.
let mut borrow = data.borrow_mut();
borrow.push(4);
assert_eq!(*data.borrow(), vec![1, 2, 3, 4]);
}
If you try to use RefCell in a multi_thread test, the compiler rejects it. RefCell isn't Send. The compiler emits E0277 (the trait bound RefCell<T>: Send is not satisfied). Send means a type can be moved across threads. RefCell tracks borrows at runtime and isn't thread-safe, so it can't be sent.
Use Arc and Mutex in multi_thread tests. Use Rc and RefCell in current_thread tests.
Match your concurrency model to your data structures. RefCell belongs in current_thread. Arc belongs in multi_thread.
Common pitfalls
Blocking the runtime is the most dangerous mistake. If you call std::thread::sleep inside an async test, you block the OS thread. The runtime can't poll other tasks. If the runtime is current_thread, the entire test deadlocks. If it's multi_thread, you starve one worker thread, which can cause cascading failures.
Always use tokio::time::sleep for delays. It yields control back to the runtime.
Forgetting await is another trap. If you call an async function but don't await the result, you get a Future that never gets polled. The test might pass falsely because the side effects never happen. The compiler warns about unused Future values, but it's easy to miss.
Panic propagation requires care. If a spawned task panics, the JoinHandle returns Err. If you ignore the handle, the panic is suppressed. The test passes even though the task failed. Always await the handle or use tokio::task::JoinSet to track tasks.
Never block the runtime. Use async primitives for everything.
Decision matrix
Use #[tokio::test] when your test function contains await or calls other async functions. Use #[test] for synchronous logic that doesn't touch the runtime; the overhead of spawning a runtime is unnecessary for pure calculations. Use #[tokio::test(flavor = "current_thread")] when you need deterministic execution order or are testing single-threaded behavior like RefCell usage. Use #[tokio::test(flavor = "multi_thread")] when you need to verify that your code works correctly under concurrent scheduling on multiple threads. Use tokio::time::pause() when your test involves timeouts or delays and you want instant execution. Use dev-dependencies for tokio to keep your production binary lean.
Pick the flavor that matches your concurrency needs. Multi-thread for stress tests, current-thread for determinism.