← Índice

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 tq para este capítulo está en 9-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.