---
title: "Testes Axum sem Servidor HTTP: Guia | Rust Brasil"
url: "https://rustlang.com.br/blog/testar-api-axum-sem-servidor-http/"
markdown_url: "https://rustlang.com.br/blog/testar-api-axum-sem-servidor-http.MD"
description: "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."
date: "2026-10-11"
author: "Equipe Rust Brasil"
---

# Testes Axum sem Servidor HTTP: Guia | Rust Brasil

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](/ecossistema/axum/) e o tutorial de [API REST em Rust](/tutoriais/api-rest-axum/).

## O que esse teste cobre?

O `Router` implementa a abstração de serviço do [Tower](/ecossistema/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](/instalacao/), execute:

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

Substitua `Cargo.toml` por:

```toml
[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:

```rust
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:

```rust
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:

```bash
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](/blog/tratamento-erros-rust-thiserror-anyhow/) 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](/ecossistema/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](/vagas/), 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](https://docs.rs/axum/0.8.6/axum/): APIs da versão usada no exemplo.
- [ServiceExt em Tower 0.5.2](https://docs.rs/tower/0.5.2/tower/trait.ServiceExt.html): contrato de `oneshot`.
- [axum::body::to_bytes](https://docs.rs/axum/0.8.6/axum/body/fn.to_bytes.html): coleta do body com limite.
- [Testes em Rust](/tutoriais/testes-rust/): organização de testes unitários, de integração e doc tests.
- [Documentação OpenAPI com Utoipa e Axum](/blog/utoipa-axum-openapi-swagger-ui-rust-2026/): descreva os mesmos status e formatos que seus testes verificam.
