How to Write Async Tests in Rust

Write async tests in Rust by adding the tokio dependency and using the #[tokio::test] attribute on your async functions.

The async test trap

You wrote an async function that fetches a user profile. You want to test it. You add #[test] to a function that returns a Future. The compiler rejects you with a mismatched types error. The test harness expects a function that runs to completion immediately. Your function returns a promise of work. The harness doesn't know how to keep that promise alive.

This happens because Rust's standard test runner is synchronous. It calls your test function, waits for it to return, and checks the result. Async functions don't return results. They return futures. A future is a state machine that says, "I'm not done yet. Poll me again later." The test runner doesn't have a poll loop. It gives up.

You need a runtime. A runtime is an event loop that drives futures to completion. #[tokio::test] provides that runtime. It's a macro that wraps your async function in a synchronous shell. The shell creates a runtime, runs your future to completion, and returns the result to the test harness.

Why the compiler complains

The #[test] attribute comes from libtest, the standard library's test framework. It expects a function signature of fn(). When you write an async function, the compiler desugars it to return an opaque type implementing Future. The signature becomes fn() -> impl Future<Output = ()>.

The test runner sees a function returning a future and panics. It can't execute a future. It needs something that runs now. The error usually looks like a type mismatch or a trait bound failure. The core issue is the same: you handed the runner a plan instead of a result.

Think of it like a manager and a chef. The manager (test runner) asks for a finished plate. The chef (async function) hands over a ticket that says, "The soup is simmering. Check back in 20 minutes." The manager doesn't know how to wait. The manager expects the plate. #[tokio::test] is a manager who understands the kitchen workflow. It takes the ticket, watches the pot, and hands the finished plate to the original manager when it's ready.

The fix: #[tokio::test]

The solution is to use the tokio crate's test attribute. This attribute requires the test-util feature. Add tokio = { version = "1", features = ["test-util"] } to your Cargo.toml under [dev-dependencies]. Mark your async test function with #[tokio::test].

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

// The function under test. It returns a future.
async fn fetch_data() -> u32 {
    // Simulate network delay.
    sleep(Duration::from_millis(10)).await;
    42
}

// The test attribute creates a runtime and drives the future.
#[tokio::test]
async fn test_fetch_data() {
    // Await the future to get the result.
    let result = fetch_data().await;
    assert_eq!(result, 42);
}

Convention aside: You'll see features = ["full"] in many tutorials. That works. The precise feature for tests is test-util. Using test-util keeps your test dependencies lean. If you only test, you don't need the full runtime features in your test binary. The community prefers precise features in production code, but full is acceptable in quick prototypes. Stick to test-util for clean Cargo.toml files.

What happens under the hood

The #[tokio::test] macro expands your code. It wraps your async function in a synchronous function. The wrapper creates a tokio::runtime::Runtime. It calls block_on on your future. block_on runs the future to completion on the current thread, handling all the polling and scheduling. When the future finishes, the wrapper returns the result.

This means your test function runs inside a runtime. You can spawn tasks, use channels, and await futures. The runtime handles the concurrency. When the test ends, the runtime drops, cleaning up any remaining tasks.

The macro also supports configuration. You can specify the runtime flavor. The default is multi_thread. This creates a runtime with multiple worker threads. Tasks can run in parallel. If you need a single-threaded runtime, you can use flavor = "current_thread". This is useful for tests that use non-Send types or need deterministic execution order.

Realistic test: Mocking time and tasks

Real async code often involves timeouts, retries, or background tasks. Testing these with real time is slow and flaky. Tokio provides pause_clock to freeze time. This makes tests instant and deterministic. You advance time manually using advance_clock.

use tokio::sync::mpsc;
use tokio::time::{sleep, Duration, pause, resume};

// A function that retries with exponential backoff.
async fn fetch_with_retry() -> String {
    let mut attempts = 0;
    loop {
        attempts += 1;
        // Simulate a fetch that fails twice.
        if attempts > 2 {
            return "Success".to_string();
        }
        // Wait before retrying.
        sleep(Duration::from_secs(1)).await;
    }
}

#[tokio::test]
async fn test_fetch_with_retry() {
    // Pause the clock to make time deterministic.
    pause();
    
    // The test runs instantly because sleep returns immediately.
    let result = fetch_with_retry().await;
    
    // Advance time to simulate the retries.
    // Each sleep(1s) consumes 1 second of virtual time.
    tokio::time::advance(Duration::from_secs(1)).await;
    tokio::time::advance(Duration::from_secs(1)).await;
    
    assert_eq!(result, "Success");
    
    // Resume the clock for other tests.
    resume();
}

Convention aside: pause() and resume() are global state. If you run tests in parallel, one test pausing the clock can affect others. Always wrap pause() and resume() in a single test, or use #[serial_test::serial] to run tests sequentially. The community treats pause_clock as a powerful tool for reliability, but it requires discipline to avoid flaky interactions.

Pitfalls: Send, blocking, and flavors

Async tests introduce specific failure modes. The most common is the Send bound. Tokio's multi-threaded runtime moves tasks between threads. Your future must be Send. If you use Rc inside an async test, the compiler rejects you with E0277 (trait bound not satisfied). Rc is not thread-safe. Use Arc instead. Or switch to the current-thread runtime.

use std::rc::Rc;

#[tokio::test]
async fn test_rc_fails() {
    // This fails to compile.
    // Error[E0277]: `Rc<u32>` cannot be sent between threads safely.
    let data = Rc::new(42);
    tokio::spawn(async move {
        println!("{}", data);
    }).await.unwrap();
}

Fix this by using Arc or changing the runtime flavor.

use std::sync::Arc;

// Use Arc for thread-safe reference counting.
#[tokio::test]
async fn test_arc_works() {
    let data = Arc::new(42);
    tokio::spawn(async move {
        println!("{}", data);
    }).await.unwrap();
}

// Or use current_thread if you don't need multi-threading.
#[tokio::test(flavor = "current_thread")]
async fn test_rc_current_thread() {
    let data = Rc::new(42);
    // spawn is not needed here; we can just await.
    println!("{}", data);
}

Another pitfall is blocking the runtime. If you call a blocking function inside an async test, you might stall the runtime. The test hangs. Use spawn_blocking for heavy CPU work or blocking I/O. Never call .wait() or a blocking loop in an async context.

Trust the Send bound. It's protecting you from data races across threads. If the compiler complains about Send, check for Rc, RefCell, or raw pointers. Replace them with thread-safe alternatives or isolate the non-Send code in a single-threaded runtime.

Decision: when to use this vs alternatives

Use #[tokio::test] when your test function is async and needs a Tokio runtime to drive futures. Use #[test] when your test is synchronous and doesn't spawn tasks or await futures. Use #[tokio::test(flavor = "current_thread")] when your test uses non-Send types like Rc and doesn't need multi-threading. Use #[tokio::test(flavor = "multi_thread")] when your test spawns multiple tasks that must run concurrently on different threads. Use tokio::time::pause() when your test relies on timeouts or delays and you need deterministic execution. Use spawn_blocking when your test calls blocking functions that would stall the event loop.

Where to go next