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égia | O que valida | O que fica de fora |
|---|---|---|
| Teste unitário de função | Regras de domínio e transformações | Roteamento e contrato HTTP |
Router com oneshot | Rotas, extractors, headers, handlers e middleware aplicado ao router | TCP, TLS e proxy |
| Servidor local com cliente HTTP | Aplicação e transporte HTTP real | Configuração do ambiente de produção |
| Teste no ambiente implantado | Integração com infraestrutura e configuração efetiva | Nem 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:
- A resposta possui um body assíncrono.
to_bytescoleta esse corpo antes deserde_json::from_slice. - 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.
- 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 testna 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
- Documentação de Axum 0.8.6: APIs da versão usada no exemplo.
- ServiceExt em Tower 0.5.2: contrato de
oneshot. - axum::body::to_bytes: coleta do body com limite.
- Testes em Rust: organização de testes unitários, de integração e doc tests.
- Documentação OpenAPI com Utoipa e Axum: descreva os mesmos status e formatos que seus testes verificam.