Fundamentos de Testes de Integração em APIs Rust
Testes de integração são essenciais para validar que diferentes camadas da sua aplicação funcionam harmoniosamente. Em Rust, especialmente com o framework axum, você testa endpoints HTTP reais, garantindo que requisições sejam processadas corretamente do início ao fim. Diferente de testes unitários que isolam funções, testes de integração verificam fluxos completos: roteamento, middlewares, lógica de negócio e respostas.
O axum::http fornece tipos como Request, Response, StatusCode e HeaderMap que você utiliza tanto na implementação quanto nos testes. A beleza do Rust é que você pode testar sua API sem disparar um servidor real — usando TestClient ou construindo requests manualmente. Isso torna os testes rápidos, isolados e determinísticos.
Configurando o Ambiente de Testes
Dependências Necessárias
Adicione ao seu Cargo.toml:
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
tower = "0.4"
hyper = "1"
serde_json = "1"
Estrutura Básica
use axum::{
routing::get,
Router,
http::{StatusCode, Request},
body::Body,
};
use tower::ServiceBuilder;
async fn hello() -> &'static str {
"Hello, World!"
}
fn app() -> Router {
Router::new()
.route("/hello", get(hello))
}
#[tokio::test]
async fn test_hello_endpoint() {
let app = app();
let client = axum_test::TestClient::new(app);
let response = client.get("/hello").send().await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await, "Hello, World!");
}
Crie um módulo separado em src/lib.rs ou src/main.rs com a função app() que retorna seu Router. Isso permite que testes importem e reutilizem a mesma configuração da aplicação sem duplicação.
Testando Endpoints com Request e Response
Validação de Status e Headers
use axum::{
routing::{get, post},
Router, Json,
http::{StatusCode, header},
};
use serde::{Deserialize, Serialize};
use tower::ServiceExt;
#[derive(Serialize, Deserialize)]
struct User {
id: u32,
name: String,
}
async fn create_user(Json(user): Json<User>) -> (StatusCode, Json<User>) {
(StatusCode::CREATED, Json(user))
}
async fn get_user() -> Json<User> {
Json(User {
id: 1,
name: "Alice".to_string(),
})
}
fn app() -> Router {
Router::new()
.route("/users", post(create_user))
.route("/users/1", get(get_user))
}
#[tokio::test]
async fn test_create_user_success() {
let app = app();
let request = Request::builder()
.method("POST")
.uri("/users")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"id":1,"name":"Alice"}"#))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn test_get_user_content_type() {
let app = app();
let request = Request::builder()
.method("GET")
.uri("/users/1")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get(header::CONTENT_TYPE)
.map(|v| v.to_str().unwrap_or(""))
.unwrap_or("")
.contains("application/json"));
}
Método .oneshot() processa uma requisição sem manter uma conexão persistente — ideal para testes. Use header::CONTENT_TYPE para validar que a resposta contém o tipo correto. Extraia e serialize o body conforme necessário para assertions mais profundas.
Testando Corpos JSON
use axum::body::to_bytes;
#[tokio::test]
async fn test_json_response_body() {
let app = app();
let request = Request::builder()
.method("GET")
.uri("/users/1")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body_bytes = to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let user: User = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(user.id, 1);
assert_eq!(user.name, "Alice");
}
Para corpos maiores, use to_bytes() do módulo axum::body. Desserialize com serde_json::from_slice() após converter os bytes. Esta abordagem funciona com qualquer tipo que implemente Deserialize.
Cenários Avançados e Boas Práticas
Testando Middlewares e Estado Compartilhado
use axum::extract::State;
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db_connection: Arc<String>,
}
async fn handler(State(state): State<AppState>) -> String {
format!("Connected to: {}", state.db_connection)
}
fn app_with_state() -> Router {
let state = AppState {
db_connection: Arc::new("test_db".to_string()),
};
Router::new()
.route("/status", get(handler))
.with_state(state)
}
#[tokio::test]
async fn test_with_state() {
let app = app_with_state();
let request = Request::builder()
.method("GET")
.uri("/status")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body_bytes = to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert!(body_str.contains("test_db"));
}
Tratamento de Erros e Respostas 4xx/5xx
use axum::http::StatusCode;
async fn forbidden_handler() -> StatusCode {
StatusCode::FORBIDDEN
}
#[tokio::test]
async fn test_forbidden_status() {
let app = Router::new()
.route("/forbidden", get(forbidden_handler));
let request = Request::builder()
.method("GET")
.uri("/forbidden")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
Validar códigos de erro é tão importante quanto validar sucesso. Seus clientes dependem de status codes corretos para lógica de retry e tratamento de erros.
Conclusão
Dominar testes de integração em Rust com axum::http envolve três pilares: (1) construir requisições manuais com Request::builder() e validar responses; (2) testar estado compartilhado e middlewares para garantir que o contexto flua corretamente; (3) validar não apenas status codes, mas também headers e corpos para cobertura real de comportamento. Pratique testando seus endpoints antes mesmo de implementar — Test-Driven Development é especialmente poderoso em Rust.