Testing async code in Rust
You wrote a function that fetches user data from an API. It works perfectly in your main function. You add a #[test] function to verify the logic. The compiler rejects you with an error about async functions not being supported in tests. Or worse, the test compiles, runs, and hangs for ten seconds while a mock delay ticks down. You are staring at a loading spinner while your coffee gets cold.
Standard Rust tests run synchronously. They expect a function that executes immediately and returns a result. Async functions do not execute immediately. They return a future, which is a description of work to be done. The test harness does not know how to poll a future to completion. You need a runtime to drive the async execution, and you need to tell the test harness to use that runtime.
Futures need a runtime to run
An async function returns a future. A future is a state machine that tracks progress. It does nothing until you poll it. Polling advances the state machine until it yields or completes. The standard #[test] attribute calls your function and expects a value. If you return a future, the test harness sees an unpolled object and fails.
Think of a future like a recipe. The recipe lists the steps to bake a cake. Reading the recipe does not bake the cake. You need a chef to follow the steps. The runtime is the chef. The #[tokio::test] attribute brings a chef into the test kitchen. It creates a runtime, calls your function to get the recipe, and polls the future until the cake is done.
Minimal example
The tokio crate provides the #[tokio::test] attribute. This attribute replaces #[test] for async functions. It sets up a runtime and polls the test future.
use tokio::time::{sleep, Duration};
/// Simulates fetching data with a delay.
async fn fetch_data() -> String {
// Sleep simulates network latency.
sleep(Duration::from_millis(100)).await;
"data".to_string()
}
#[tokio::test]
async fn test_fetch_data() {
// Await the future to drive it to completion.
let result = fetch_data().await;
assert_eq!(result, "data");
}
The #[tokio::test] macro expands to a synchronous function that creates a runtime. It calls the async function, polls the resulting future, and checks for panics. If the future panics, the test fails. If the future completes, the assertions run. This bridges the gap between the synchronous test harness and asynchronous code.
The attribute handles the boilerplate. You write the logic.
Testing concurrency
Async code often involves spawning tasks and coordinating them. Tests should verify that concurrent tasks produce the correct results. Use tokio::spawn to start tasks and tokio::join! to wait for them.
use tokio::task::JoinHandle;
/// Doubles an input value after a short delay.
async fn worker(id: u32) -> u32 {
// Simulate work.
sleep(Duration::from_millis(10)).await;
id * 2
}
#[tokio::test]
async fn test_concurrent_workers() {
// Spawn tasks to run concurrently.
let h1 = tokio::spawn(worker(1));
let h2 = tokio::spawn(worker(2));
// Join handles return Results. Unwrap to get values.
let (r1, r2) = tokio::join!(h1, h2);
assert_eq!(r1.unwrap(), 2);
assert_eq!(r2.unwrap(), 4);
}
tokio::spawn returns a JoinHandle. The handle is a promise that the task will complete. tokio::join! waits for all handles to finish and returns a tuple of results. The macro is preferred over manual join_all for fixed sets of tasks because it preserves types and avoids allocations.
Convention aside: Always unwrap JoinHandle results in tests unless you are testing error handling. A panic in a spawned task turns into an error in the handle. Unwrapping propagates the panic and fails the test immediately, which is the desired behavior.
Join handles are promises. Unwrap them to get the result.
Controlling time with pause
Tests that depend on real time are slow and flaky. A test for a five-second timeout takes five seconds. Network jitter can cause delays to vary. Use tokio::time::pause() to switch the runtime to virtual time. Sleeps and timeouts complete instantly, making tests deterministic and fast.
use tokio::time::{sleep, Duration};
/// Waits for a duration before returning success.
async fn delayed_task() -> bool {
sleep(Duration::from_secs(1)).await;
true
}
#[tokio::test(flavor = "current_thread")]
async fn test_delayed_task_paused() {
// Pause time to make sleeps instant.
tokio::time::pause();
// This sleep completes immediately because time is paused.
let result = delayed_task().await;
assert!(result);
}
pause() changes the behavior of the runtime's timer. It does not stop the CPU. It makes sleep return immediately without waiting. This is essential for testing retry logic, timeouts, and scheduled tasks. Without pause, tests for time-based behavior are impractical.
Convention aside: Always use pause() with flavor = "current_thread". Pausing time is global state within the runtime. In a multi-threaded runtime, one test might pause time while another test is running, causing race conditions. The current_thread flavor isolates the runtime to a single thread, making pause safe.
Pause time to kill flakiness. Tests should be instant.
Pitfalls and compiler errors
Async tests introduce specific failure modes. The compiler catches many of them, but the errors can be confusing if you do not know what to look for.
Using #[test] on an async function fails with error[E0752]: async fn main is not supported or a variant about test functions returning futures. The test harness expects a synchronous function. The fix is to use #[tokio::test] or the equivalent attribute for your runtime.
Using #[tokio::test] with non-Send types fails with E0277: future cannot be sent between threads safely. The default runtime flavor is multi-threaded. Tasks can move between threads. If your future captures a reference to a local variable, that variable must implement Send. Rc<T> and RefCell<T> are not Send. The compiler rejects the test. The fix is to switch to Arc<T> and Mutex<T>, or use flavor = "current_thread".
Blocking the runtime with synchronous sleep causes deadlocks. Calling std::thread::sleep inside an async test blocks the worker thread. In a current_thread runtime, this stops all progress. The test hangs forever. Use tokio::time::sleep instead. It yields control back to the runtime.
Check the error code. It tells you exactly what trait is missing.
Decision matrix
Use #[tokio::test] for standard async tests when your code runs on the default multi-threaded runtime and your futures implement Send. Use #[tokio::test(flavor = "current_thread")] when your test uses non-Send types like Rc or RefCell, or when you need to pause time for deterministic scheduling. Use #[tokio::test(flavor = "multi_thread", worker_threads = N)] when you are testing concurrency patterns that require multiple worker threads to expose race conditions or deadlock scenarios. Use tokio::time::pause() when your code depends on sleep or timeouts and you want tests to run instantly without real delays. Use #[async_std::test] when your project uses the async-std ecosystem instead of tokio. Reach for #[test] with synchronous mocks when you can isolate the async boundary and test the logic without a runtime.