9.2 Prueba en el puerto
Esta traducción fue asistida por IA y revisada superficialmente. Si encuentras errores o algo no suena natural, escríbeme — lo agradezco.
Un artesano que sabe cómo comprobar una pieza a mano la comprueba como lo haría un artesano. Un visitante lo hace de otra manera: llega con un asunto, llama a la puerta y espera una respuesta - como llama todo el mundo. Entre esos dos tipos de comprobación hay toda una clase de errores que el artesano no ve precisamente porque es el artesano.
Lo que necesitas
Para testear rutas HTTP hay que enviar una petición y comprobar la respuesta. Y para que un
test pueda llamar a router(), debe poder importarla. La solución es estándar: añadir
crates/api/src/lib.rs. Cuando un paquete tiene tanto lib.rs como main.rs, Cargo
construye ambos - el router pasa a formar parte de la biblioteca y main lo usa como
dependencia.
El objetivo es testear el router y los handlers, no la pila de red. Axum proporciona
oneshot de tower::ServiceExt para esto - envía una petición directamente al router y
recibe una respuesta, sin servidor, sin puerto, sin red. La respuesta es la misma que
recibiría un cliente real: el router procesa la petición de forma idéntica
independientemente de cómo haya llegado.
Este es el enfoque que la documentación de axum recomienda para testear handlers.
La construcción
Crea crates/api/src/lib.rs:
pub mod routes;
En crates/api/src/main.rs, elimina las líneas mod routes; y use routes::{...} - el
módulo pertenece ahora al crate biblioteca. Sustitúyelas por una importación desde tq_api:
use axum::{Router, routing::get};
use std::path::Path;
use std::sync::{Arc, Mutex};
use tq_api::routes::{AppState, SharedStore, router};
use tq_core::config::Config;
use tq_core::persistence;
use tq_core::store::TaskStore;
En tests/Cargo.toml, añade un nuevo target y dependencias. Fíjate en [dev-dependencies]
en lugar de [dependencies] - estos paquetes se compilan solo para tests y no acaban en el
binario de release:
[[test]]
name = "api"
path = "tests/api.rs"
[dev-dependencies]
tq-core = { path = "../crates/core" }
tq-api = { path = "../crates/api" }
axum = "0.8"
tower = { version = "0.5", features = ["util"] }
tempfile = "3"
tokio = { version = "1", features = ["full"] }
serde_json = "1"
Para tomar las versiones más recientes en el momento de leer esto en lugar de editarlas a
mano, usa cargo add con el flag --dev. tower debe añadirse por separado - por el
feature util:
cargo add --dev axum tempfile tokio serde_json -p tq-tests
cargo add --dev tower --features util -p tq-tests
Las versiones del ejemplo anterior están probadas - funcionan juntas.
Ahora tests/tests/api.rs. Dos funciones auxiliares reducen la repetición - no llevan
lógica, solo eliminan ruido:
use axum::body::Body;
use axum::http::{Request, StatusCode};
use serde_json::Value;
use std::sync::{Arc, Mutex};
use tq_api::routes::{AppState, router};
use tq_core::store::TaskStore;
use tower::ServiceExt;
fn make_state() -> (AppState, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let state = AppState {
store: Arc::new(Mutex::new(TaskStore::new(vec![]))),
data_path: dir.path().join("tasks.json"),
};
(state, dir)
}
async fn body_json(res: axum::response::Response) -> Value {
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
make_state() crea un almacén nuevo para cada test. state.clone() dentro de un test es
un clon barato de Arc: todos los clones miran el mismo almacén, de modo que varias
llamadas oneshot dentro de un mismo test comparten los mismos datos.
#[tokio::test]
async fn add_task_returns_task() {
let (state, _dir) = make_state();
let res = router(state)
.oneshot(
Request::builder()
.method("POST")
.uri("/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"Buy coffee"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = body_json(res).await;
assert_eq!(body["title"], "Buy coffee");
assert_eq!(body["status"], "Todo");
}
#[tokio::test]
async fn list_returns_added_tasks() {
let (state, _dir) = make_state();
for json in [r#"{"title":"Buy coffee"}"#, r#"{"title":"Write tests"}"#] {
router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/tasks")
.header("content-type", "application/json")
.body(Body::from(json))
.unwrap(),
)
.await
.unwrap();
}
let res = router(state)
.oneshot(Request::builder().uri("/tasks").body(Body::empty()).unwrap())
.await
.unwrap();
let tasks: Vec<Value> = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap(),
)
.unwrap();
assert_eq!(tasks.len(), 2);
}
#[tokio::test]
async fn done_marks_task_complete() {
let (state, _dir) = make_state();
let res = router(state.clone())
.oneshot(
Request::builder()
.method("POST")
.uri("/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"Buy coffee"}"#))
.unwrap(),
)
.await
.unwrap();
let id = body_json(res).await["id"].as_u64().unwrap();
let res = router(state)
.oneshot(
Request::builder()
.method("PATCH")
.uri(format!("/tasks/{id}/done"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(body_json(res).await["status"], "Done");
}
#[tokio::test]
async fn get_unknown_task_returns_404() {
let (state, _dir) = make_state();
let res = router(state)
.oneshot(Request::builder().uri("/tasks/999").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
El resultado
$ make ci
...
Running tests/api.rs (target/debug/deps/api-...)
running 4 tests
test add_task_returns_task ... ok
test done_marks_task_complete ... ok
test get_unknown_task_returns_404 ... ok
test list_returns_added_tasks ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
El puerto se prueba ahora a sí mismo.
El código completo de
tqpara este capítulo está en9-shippable/02-proof-at-the-port/.
Lore: oneshot y Tower
Axum no inventó el testing de handlers - lo heredó de Tower. En Tower, cualquier servicio
puede testearse a través de ServiceExt::oneshot: una petición, una respuesta, sin pila de
red. Esto funciona porque Router implementa el trait Service<Request> - la misma interfaz
que usa un servidor HTTP real. El test llega al mismo código que el cliente de producción,
solo que sin la capa TCP en medio.
Cuando un test es más complejo - por ejemplo, necesitas mantener una conexión activa o
comprobar el comportamiento bajo carga - se levanta un servidor real en el puerto 0. Para
verificar que las rutas y los handlers son correctos, oneshot es suficiente y más rápido.