Testing async code com tokio-test

1. Introdução ao tokio-test

Testar código assíncrono em Rust apresenta desafios únicos. Diferente de funções síncronas, futures precisam de um runtime para serem executadas, e o comportamento temporal pode tornar testes lentos ou imprevisíveis. O tokio-test é uma biblioteca auxiliar que fornece ferramentas para testar código assíncrono baseado no runtime Tokio de forma determinística e eficiente.

Enquanto testes síncronos executam imediatamente e sequencialmente, testes assíncronos exigem que o runtime agende e execute as tarefas. O tokio-test resolve isso oferecendo utilitários como block_on, assert_ready e controle manual do relógio virtual.

Para instalar, adicione ao Cargo.toml:

[dev-dependencies]
tokio = { version = "1", features = ["full"] }
tokio-test = "0.4"

2. Fundamentos do block_on e assert_ready

O block_on permite executar um future em um contexto síncrono, bloqueando a thread atual até a conclusão. É útil para testes unitários simples.

use tokio_test::block_on;

async fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[test]
fn test_add_async() {
    let result = block_on(add(2, 3));
    assert_eq!(result, 5);
}

Alternativamente, o macro #[tokio::test] gerencia o runtime automaticamente:

#[tokio::test]
async fn test_add_with_macro() {
    let result = add(2, 3).await;
    assert_eq!(result, 5);
}

Para testar estados parciais de futures, use assert_ready e assert_pending:

use tokio_test::{assert_ready, assert_pending};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

#[tokio::test]
async fn test_future_states() {
    let mut future = Box::pin(add(1, 2));
    let waker = tokio_test::task::noop_waker();
    let mut cx = Context::from_waker(&waker);

    assert_ready!(future.as_mut().poll(&mut cx));
}

3. Testando com tokio::test e gerenciamento de runtime

O macro #[tokio::test] aceita configurações de runtime:

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_concurrent_tasks() {
    let handle = tokio::spawn(async {
        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
        42
    });

    let result = handle.await.unwrap();
    assert_eq!(result, 42);
}

Para testar panics em código assíncrono:

async fn might_panic(should_panic: bool) -> i32 {
    if should_panic {
        panic!("deu ruim!");
    }
    0
}

#[tokio::test]
#[should_panic(expected = "deu ruim!")]
async fn test_async_panic() {
    might_panic(true).await;
}

4. Simulação de tempo com tokio_test::clock

Testar timeouts sem esperar o tempo real é essencial. O tokio_test::clock permite avançar o relógio manualmente:

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

#[tokio::test]
async fn test_sleep_with_clock() {
    clock::pause(); // Congela o relógio

    let sleep_future = sleep(Duration::from_secs(5));
    tokio::pin!(sleep_future);

    // Avança 3 segundos
    clock::advance(Duration::from_secs(3)).await;
    assert!(sleep_future.as_mut().poll(&mut tokio_test::task::spawn(()).cx()).is_pending());

    // Avança mais 2 segundos para completar
    clock::advance(Duration::from_secs(2)).await;
    assert!(sleep_future.as_mut().poll(&mut tokio_test::task::spawn(()).cx()).is_ready());
}

Testando um timeout real:

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

async fn slow_operation() -> &'static str {
    sleep(Duration::from_secs(10)).await;
    "concluído"
}

#[tokio::test]
async fn test_timeout() {
    clock::pause();

    let result = timeout(Duration::from_secs(5), slow_operation());
    tokio::pin!(result);

    clock::advance(Duration::from_secs(3)).await;
    assert!(result.as_mut().poll(&mut tokio_test::task::spawn(()).cx()).is_pending());

    clock::advance(Duration::from_secs(3)).await;
    let output = assert_ready!(result.as_mut().poll(&mut tokio_test::task::spawn(()).cx()));
    assert!(output.is_err()); // Timeout ocorreu
}

5. Testando streams e canais com tokio_test

Para testar streams assíncronos, use block_on_stream:

use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tokio_test::block_on_stream;

#[tokio::test]
async fn test_mpsc_channel() {
    let (tx, rx) = mpsc::channel(10);

    tokio::spawn(async move {
        for i in 0..3 {
            tx.send(i).await.unwrap();
        }
    });

    let mut stream = block_on_stream(rx);
    assert_eq!(stream.next(), Some(1));
    assert_eq!(stream.next(), Some(2));
    assert_eq!(stream.next(), Some(3));
    assert_eq!(stream.next(), None);
}

Testando estados de canais com assert_ready:

use tokio::sync::oneshot;

#[tokio::test]
async fn test_oneshot() {
    let (tx, mut rx) = oneshot::channel();

    tokio::spawn(async move {
        tx.send(42).unwrap();
    });

    let mut task = tokio_test::task::spawn(async {
        rx.await.unwrap()
    });

    assert_pending!(task.poll());
    // Após o spawn completar:
    assert_eq!(task.await, 42);
}

6. Mock de recursos externos com tokio::io e tokio::net

O tokio_test::io::Builder simula operações de I/O:

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_test::io::Builder;

#[tokio::test]
async fn test_mock_io() {
    let mut mock = Builder::new()
        .read(b"hello world")
        .write(b"echo: hello world")
        .build();

    let mut buf = vec![0u8; 11];
    mock.read_exact(&mut buf).await.unwrap();
    assert_eq!(&buf, b"hello world");

    mock.write_all(b"echo: hello world").await.unwrap();
}

Testando um servidor TCP simulado:

use tokio::net::TcpListener;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[tokio::test]
async fn test_tcp_server() {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();

    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut buf = [0u8; 1024];
        let n = socket.read(&mut buf).await.unwrap();
        socket.write_all(&buf[..n]).await.unwrap();
    });

    let mut client = tokio::net::TcpStream::connect(addr).await.unwrap();
    client.write_all(b"ping").await.unwrap();

    let mut buf = vec![0u8; 4];
    client.read_exact(&mut buf).await.unwrap();
    assert_eq!(&buf, b"ping");

    server.await.unwrap();
}

7. Padrões avançados e boas práticas

Combinando tokio-test com mockall para mocks de dependências:

use mockall::automock;

#[automock]
trait AsyncService {
    async fn fetch_data(&self) -> Result<String, String>;
}

#[tokio::test]
async fn test_with_mock() {
    let mut mock = MockAsyncService::new();
    mock.expect_fetch_data()
        .returning(|| Box::pin(async { Ok("mocked data".to_string()) }));

    let result = mock.fetch_data().await;
    assert_eq!(result.unwrap(), "mocked data");
}

Testando graceful shutdown:

use tokio::sync::oneshot;

async fn server_loop(mut shutdown_rx: oneshot::Receiver<()>) {
    tokio::select! {
        _ = &mut shutdown_rx => {
            println!("Shutting down gracefully");
        }
    }
}

#[tokio::test]
async fn test_graceful_shutdown() {
    let (tx, rx) = oneshot::channel();

    let handle = tokio::spawn(server_loop(rx));
    tx.send(()).unwrap();

    tokio::time::timeout(Duration::from_millis(100), handle)
        .await
        .expect("Server should shut down quickly")
        .unwrap();
}

Dicas para evitar race conditions:
- Use tokio_test::clock::pause() para controlar o tempo
- Evite tokio::spawn desnecessário em testes unitários
- Prefira assert_ready! e assert_pending! para verificar estados intermediários
- Mantenha testes determinísticos usando tokio_test::task::spawn

8. Limitações e alternativas

O tokio-test é excelente para testes unitários e de integração controlados, mas tem limitações:

  • Testes de integração reais: Para testar sistemas distribuídos ou I/O real, use #[tokio::test] sem manipulação de clock
  • Mocks complexos: Para dependências externas, combine com mockall ou crie stubs manuais
  • Performance: Testes com block_on em múltiplas threads podem ser lentos; prefira #[tokio::test] single-thread

Alternativas:
- futures-test: Similar, mas para o ecossistema futures-rs
- async-std: Runtime alternativo com seus próprios utilitários de teste
- Testes manuais com block_on simples para casos triviais

Para suites de teste grandes, considere separar testes unitários (com tokio-test) de testes de integração (com runtime real) para garantir velocidade e precisão.

Referências