Testes Axum sem Servidor HTTP: Guia

Teste APIs Axum sem abrir portas: use Router, Tower oneshot e Tokio para verificar JSON, status HTTP, erros de validação e isolamento dos testes em Rust.

Para testar uma API Axum sem abrir uma porta HTTP, envie uma Request ao Router com tower::ServiceExt::oneshot e verifique o status, os headers e o corpo da resposta. Você exercita roteamento, extractors e handlers no mesmo processo do teste, sem escolher uma porta livre nem iniciar um servidor em segundo plano. O método é útil para detectar regressões no contrato HTTP durante o desenvolvimento de backends Rust.

Este tutorial cria uma API mínima com uma rota de cadastro e seis testes executáveis. Não há banco, autenticação ou persistência: vamos isolar o comportamento HTTP antes de adicionar infraestrutura. Para entender a aplicação que está sendo testada, consulte o guia de Axum e o tutorial de API REST em Rust.

O que esse teste cobre?

O Router implementa a abstração de serviço do Tower. Em vez de aguardar uma conexão, podemos entregar uma requisição diretamente ao serviço e obter uma resposta. oneshot consome essa instância do serviço e conduz a chamada, incluindo a etapa de prontidão.

EstratégiaO que validaO que fica de fora
Teste unitário de funçãoRegras de domínio e transformaçõesRoteamento e contrato HTTP
Router com oneshotRotas, extractors, headers, handlers e middleware aplicado ao routerTCP, TLS e proxy
Servidor local com cliente HTTPAplicação e transporte HTTP realConfiguração do ambiente de produção
Teste no ambiente implantadoIntegração com infraestrutura e configuração efetivaNem sempre permite isolar a causa da falha

Esse teste pode viver em tests/ e ser um teste de integração no sentido do Cargo, mesmo sem usar rede. Não confunda integração entre componentes da aplicação com um teste de ponta a ponta de todo o sistema.

1. Crie uma biblioteca para expor o Router

Com Rust e Cargo instalados, execute:

cargo new axum-testes --lib
cd axum-testes
mkdir -p tests

Substitua Cargo.toml por:

[package]
name = "axum-testes"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "=0.8.6"
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower = { version = "=0.5.2", features = ["util"] }

As versões de Axum e Tower estão fixadas para reproduzir as APIs deste exemplo; não são uma afirmação sobre as versões mais recentes. Use uma toolchain stable atualizada e preserve o Cargo.lock do projeto de estudo para registrar também as dependências transitivas.

tokio executa os testes assíncronos. A feature util de Tower disponibiliza ServiceExt, o trait que fornece oneshot. serde_json fica nas dependências de desenvolvimento porque será usado explicitamente pelas assertions, enquanto o Json de Axum faz a serialização nas rotas.

2. Implemente a aplicação

Substitua src/lib.rs por:

use axum::{http::StatusCode, routing::post, Json, Router};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct NovoUsuario {
    nome: String,
}

#[derive(Serialize)]
struct Usuario {
    nome: String,
}

#[derive(Serialize)]
struct Erro {
    codigo: &'static str,
}

async fn criar_usuario(
    Json(entrada): Json<NovoUsuario>,
) -> Result<(StatusCode, Json<Usuario>), (StatusCode, Json<Erro>)> {
    let nome = entrada.nome.trim();
    if nome.is_empty() {
        return Err((
            StatusCode::UNPROCESSABLE_ENTITY,
            Json(Erro { codigo: "nome_vazio" }),
        ));
    }

    Ok((
        StatusCode::CREATED,
        Json(Usuario { nome: nome.to_owned() }),
    ))
}

pub fn app() -> Router {
    Router::new().route("/usuarios", post(criar_usuario))
}

A função pública app() é a fronteira de montagem da aplicação. O mesmo router pode ser usado futuramente por um binário com axum::serve, mas os testes não precisam desse binário.

O extractor Json<NovoUsuario> faz a desserialização antes de chamar o handler. Portanto, erro de sintaxe, Content-Type incompatível e ausência de um campo obrigatório podem impedir a execução de criar_usuario. Já o nome composto apenas por espaços chega ao handler e recebe o erro de domínio nome_vazio.

Apesar do nome da rota, esta API não grava usuários. Ela valida e devolve a representação recebida. O status 201 faz parte do contrato didático; em um serviço real, só confirme a criação depois que a operação de persistência tiver sido bem-sucedida.

3. Teste JSON, status e erros

Crie tests/http.rs com o conteúdo completo abaixo:

use axum::{
    body::{to_bytes, Body},
    http::{header::CONTENT_TYPE, Request, StatusCode},
};
use axum_testes::app;
use serde_json::{json, Value};
use tower::ServiceExt;

#[tokio::test]
async fn cria_usuario_e_normaliza_nome() {
    let request = Request::builder()
        .method("POST")
        .uri("/usuarios")
        .header(CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"nome":"  Ana  "}"#))
        .unwrap();

    let response = app().oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::CREATED);
    assert_eq!(response.headers()[CONTENT_TYPE], "application/json");

    let bytes = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
    let body: Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(body, json!({"nome": "Ana"}));
}

#[tokio::test]
async fn rejeita_nome_vazio() {
    let request = Request::builder()
        .method("POST")
        .uri("/usuarios")
        .header(CONTENT_TYPE, "application/json")
        .body(Body::from(r#"{"nome":"   "}"#))
        .unwrap();

    let response = app().oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);

    let bytes = to_bytes(response.into_body(), 16 * 1024).await.unwrap();
    let body: Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(body, json!({"codigo": "nome_vazio"}));
}

#[tokio::test]
async fn rejeita_json_com_sintaxe_invalida() {
    let request = Request::builder()
        .method("POST")
        .uri("/usuarios")
        .header(CONTENT_TYPE, "application/json")
        .body(Body::from("{"))
        .unwrap();

    let response = app().oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn rejeita_json_sem_campo_obrigatorio() {
    let request = Request::builder()
        .method("POST")
        .uri("/usuarios")
        .header(CONTENT_TYPE, "application/json")
        .body(Body::from("{}"))
        .unwrap();

    let response = app().oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}

#[tokio::test]
async fn exige_content_type_json() {
    let request = Request::builder()
        .method("POST")
        .uri("/usuarios")
        .body(Body::from(r#"{"nome":"Ana"}"#))
        .unwrap();

    let response = app().oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
}

#[tokio::test]
async fn rota_inexistente_retorna_404() {
    let request = Request::builder()
        .uri("/nao-existe")
        .body(Body::empty())
        .unwrap();

    let response = app().oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

Execute:

cargo test
cargo test --test http

O segundo comando executa somente o arquivo de integração tests/http.rs. Você deve ver seis testes passando. O import usa axum_testes, com underscore, porque o nome do pacote axum-testes é normalizado para o identificador da crate em Rust.

Observe três detalhes importantes:

  1. A resposta possui um body assíncrono. to_bytes coleta esse corpo antes de serde_json::from_slice.
  2. A coleta tem um limite explícito. Os 16 KiB protegem o teste contra uma resposta inesperadamente grande; não configuram o limite de entrada do servidor.
  3. Comparamos JSON como dados. A igualdade entre valores JSON não depende da ordem textual das chaves nem da indentação.

Os unwrap() ficam no teste: qualquer falha na montagem da requisição, na chamada ou na leitura faz o teste falhar. Isso não é uma recomendação para usar unwrap() em handlers de produção.

4. Diferencie falha de extração e falha de domínio

No exemplo, dois cenários retornam 422: um documento sem nome e um nome vazio. Eles percorrem caminhos diferentes. O primeiro é uma rejection do extractor; o segundo é uma resposta JSON definida por nós.

Não exija que o texto da rejection padrão seja idêntico ao JSON de domínio. Se sua API promete um envelope uniforme, implemente o mapeamento de rejections e teste esse novo contrato. O guia de tratamento de erros em Rust ajuda a separar erro interno, erro de entrada e resposta pública.

Também não transforme toda falha em 400 indiscriminadamente. Status diferentes ajudam clientes a distinguir formato inválido, mídia não suportada, recurso inexistente e regras de negócio. Quando houver autenticação, acrescente casos sem credencial, credencial expirada e permissão insuficiente, usando a configuração real de middleware da aplicação.

5. Estado e isolamento: cuidado com Router::clone

Cada teste chama app() para construir uma aplicação nova. Se você adicionar State<Arc<...>>, mantenha essa mesma ideia: cada teste deve receber sua própria fixture quando precisar de isolamento.

Router::clone() não garante uma cópia independente dos dados. Clonar um Arc compartilha o mesmo estado. Isso é útil para enviar várias requisições à mesma aplicação dentro de um teste, mas pode provocar interferência quando fixtures globais são reutilizadas em testes paralelos.

Para um backend com SQLx, defina uma estratégia explícita: banco ou schema exclusivo por teste, limpeza controlada ou transação quando a arquitetura permitir que todas as operações usem aquela transação. Apenas abrir uma transação na fixture não isola automaticamente handlers que obtêm outras conexões do pool.

Checklist para levar à sua API

  • Expor uma função de montagem do router sem abrir um socket.
  • Testar sucesso, entrada inválida e rota inexistente.
  • Validar status e headers antes de consumir o body.
  • Comparar respostas JSON semanticamente.
  • Aplicar no teste o mesmo middleware relevante da aplicação.
  • Criar fixtures isoladas para estado mutável e banco.
  • Manter testes de rede para TLS, proxy e configuração do servidor.
  • Rodar cargo test na CI e registrar dependências reproduzíveis.

Como próximo exercício, implemente persistência e teste que uma segunda requisição consegue consultar o usuário criado. Esse fluxo torna o projeto mais útil para um portfólio de backend Rust, desde que a documentação deixe claro o que o exemplo realmente garante.

Perguntas frequentes

Como testar uma API Axum sem iniciar um servidor?

Construa o Router e use tower::ServiceExt::oneshot para enviar uma Request diretamente ao serviço. O teste executa roteamento, extractors, handlers e middleware instalado no router, sem criar um socket TCP.

Por que oneshot não aparece no Router?

Importe tower::ServiceExt e habilite a feature util da dependência Tower. Neste exemplo, Axum 0.8.6 e Tower 0.5.2 são compatíveis; verifique as versões se o compilador não encontrar a implementação esperada do trait.

Testar com oneshot substitui testes de ponta a ponta?

Não. TCP, TLS, proxy reverso e opções de axum::serve não são exercitados. Use oneshot para o contrato da aplicação e um cliente HTTP contra o servidor real para essas fronteiras.

Qual status Axum retorna para JSON inválido?

Com o extractor padrão, mídia incompatível retorna 415, JSON com sintaxe inválida retorna 400 e JSON incompatível com o tipo esperado retorna 422. Uma implementação própria de rejections pode alterar essas respostas.

Referências e próximos passos