← Volver al listado de tecnologías

Capitulo 13: Testing - Unitarios, Mocking y Estrategia

Por: SiempreListo
rustwasmaxumleptos

Estrategia de testing en Rust

Rust tiene soporte de testing integrado en el lenguaje. No necesitas frameworks externos para lo basico: #[test], assert!, assert_eq! y modulos #[cfg(test)] vienen incluidos.

La estrategia de testing de este proyecto se enfoca en lo que mas valor aporta:

NivelQue se testeaHerramienta
UnitarioEntidades y value objects#[test] nativo
MockingUse cases con repositorio falsomockall
IntegracionHandlers HTTP + BDPendiente

La logica de dominio es pura (sin I/O, sin side effects), por lo que es trivial de testear. Los adapters de infraestructura (SQLite, HTTP) requieren mas setup y quedan como mejora futura.

Tests unitarios de la entidad Card

Los tests de Card verifican las reglas de negocio del dominio:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_card_defaults_to_todo() {
        let card = Card::new("Test".into(), "Desc".into());
        assert_eq!(card.lane, Lane::Todo);
        assert!(!card.completed);
        assert_eq!(card.position, 0);
    }

    #[test]
    fn move_to_done_marks_completed() {
        let mut card = Card::new("Test".into(), String::new());
        card.move_to(Lane::Done, 1);
        assert!(card.completed);
        assert_eq!(card.lane, Lane::Done);
        assert_eq!(card.position, 1);
    }

    #[test]
    fn move_from_done_to_todo_unmarks_completed() {
        let mut card = Card::new("Test".into(), String::new());
        card.move_to(Lane::Done, 0);
        card.move_to(Lane::Todo, 0);
        assert!(!card.completed);
    }
}

Que valida cada test

new_card_defaults_to_todo: una tarjeta nueva siempre nace en la columna Todo, sin completar y en posicion 0. Esto es una invariante de negocio: nadie crea una tarjeta ya completada.

move_to_done_marks_completed: mover a Done marca automaticamente completed = true. La entidad encapsula esta logica en vez de depender del caller.

move_from_done_to_todo_unmarks_completed: si mueves una tarjeta de Done de vuelta a Todo, se desmarca completed. Esto prueba que move_to no es un setter simple; tiene logica de negocio que depende del valor de lane.is_done().

Tests del value object Lane

El enum Lane tiene su propio conjunto de tests:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn try_from_valid_lanes() {
        assert_eq!(Lane::try_from("todo").unwrap(), Lane::Todo);
        assert_eq!(Lane::try_from("doing").unwrap(), Lane::Doing);
        assert_eq!(Lane::try_from("done").unwrap(), Lane::Done);
    }

    #[test]
    fn try_from_invalid_lane() {
        assert!(Lane::try_from("invalid").is_err());
    }

    #[test]
    fn is_done_only_for_done() {
        assert!(!Lane::Todo.is_done());
        assert!(!Lane::Doing.is_done());
        assert!(Lane::Done.is_done());
    }
}

Que valida cada test

try_from_valid_lanes: los tres strings validos (“todo”, “doing”, “done”) se convierten correctamente al enum. Esto asegura que la deserializacion de JSON funciona.

try_from_invalid_lane: cualquier string que no sea un lane valido retorna Err. Esto protege contra datos corruptos del frontend o la BD.

is_done_only_for_done: solo Lane::Done retorna true en is_done(). Este metodo es usado por Card::move_to para decidir si marcar completed, asi que su correctitud es critica.

Compilacion condicional con #[cfg(test)]

#[cfg(test)]
mod tests {
    use super::*;
    // ...
}

El atributo #[cfg(test)] le dice al compilador: “este modulo solo existe cuando ejecutas cargo test”. En builds normales (cargo build) se ignora completamente.

Ventajas de este enfoque:

mockall: mocking automatico de traits

El trait CardRepository del backend usa mockall para generar mocks automaticamente:

use crate::domain::entities::Card;
use crate::domain::errors::DomainError;
use crate::domain::value_objects::CardId;

#[cfg_attr(test, mockall::automock)]
pub trait CardRepository: Send + Sync {
    fn find_all(
        &self,
    ) -> impl std::future::Future<Output = Result<Vec<Card>, DomainError>> + Send;

    fn find_by_id(
        &self,
        id: &CardId,
    ) -> impl std::future::Future<Output = Result<Card, DomainError>> + Send;

    fn save(
        &self,
        card: &Card,
    ) -> impl std::future::Future<Output = Result<(), DomainError>> + Send;

    fn update(
        &self,
        card: &Card,
    ) -> impl std::future::Future<Output = Result<(), DomainError>> + Send;

    fn delete(
        &self,
        id: &CardId,
    ) -> impl std::future::Future<Output = Result<(), DomainError>> + Send;
}

Como funciona mockall

El atributo #[cfg_attr(test, mockall::automock)] hace dos cosas:

  1. cfg_attr(test, ...): solo aplica el atributo en compilacion de test
  2. mockall::automock: genera una struct MockCardRepository con metodos expect_* para cada metodo del trait

El mock generado permite:

// Ejemplo de como se usaria en un test de use case
let mut mock_repo = MockCardRepository::new();
mock_repo
    .expect_find_all()
    .returning(|| Box::pin(async { Ok(vec![]) }));

Por que Send + Sync en el trait

El backend usa Tokio multi-threaded. Los traits que se pasan entre tareas async necesitan Send + Sync. El bound + Send en el Future garantiza que el future se puede enviar a otro thread del executor.

Ejemplo: testeando un use case con mock

Aunque el proyecto no incluye estos tests aun, asi se testearia un use case:

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn list_cards_returns_all() {
        let mut mock = MockCardRepository::new();
        mock.expect_find_all()
            .returning(|| Box::pin(async {
                Ok(vec![Card::new("Task 1".into(), "Desc".into())])
            }));

        let use_case = ListCardsUseCase::new(Arc::new(mock));
        let cards = use_case.execute().await.unwrap();
        assert_eq!(cards.len(), 1);
    }
}

El patron es:

  1. Crear el mock con MockCardRepository::new()
  2. Configurar expectativas con expect_*().returning(...)
  3. Inyectar el mock en el use case
  4. Ejecutar y verificar el resultado

Ejecutar los tests

# Ejecutar todos los tests del workspace
cargo test

# Solo tests del backend
cargo test -p app-backend

# Un test especifico
cargo test -p app-backend new_card_defaults_to_todo

# Con output detallado
cargo test -p app-backend -- --nocapture

Salida tipica:

running 6 tests
test domain::entities::card::tests::new_card_defaults_to_todo ... ok
test domain::entities::card::tests::move_to_done_marks_completed ... ok
test domain::entities::card::tests::move_from_done_to_todo_unmarks_completed ... ok
test domain::value_objects::lane::tests::try_from_valid_lanes ... ok
test domain::value_objects::lane::tests::try_from_invalid_lane ... ok
test domain::value_objects::lane::tests::is_done_only_for_done ... ok

test result: ok. 6 passed; 0 failed; 0 ignored

Areas sin tests y como mejorar

Tests de handlers HTTP

Se podrian testear los handlers de Axum usando tower::ServiceExt:

// Ejemplo conceptual
let app = create_router(repo);
let response = app
    .oneshot(Request::get("/api/cards").body(Body::empty()).unwrap())
    .await
    .unwrap();
assert_eq!(response.status(), StatusCode::OK);

Esto testea el handler completo incluyendo deserializacion, routing y respuesta HTTP.

Tests de integracion con SQLite in-memory

SQLx soporta bases de datos en memoria, ideales para tests:

let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let repo = SqliteCardRepository::new(pool);
// Testear operaciones reales contra la BD

Tests de componentes Leptos

Leptos no tiene un framework de testing de componentes maduro aun. Las opciones actuales son:

Glosario

TerminoDefinicion
#[cfg(test)]Atributo que compila codigo solo durante cargo test
mockallCrate que genera structs mock a partir de traits
#[cfg_attr(test, …)]Aplica un atributo condicionalmente solo en modo test
#[tokio::test]Macro que crea un runtime Tokio para tests async
Send + SyncBounds de Rust que permiten enviar datos entre threads
tower::ServiceExtExtension trait para testear servicios HTTP de tower/axum

← Anterior: Drag & Drop y Gateway HTTP | Siguiente: Decisiones y Trade-offs →