Capitulo 13: Testing - Unitarios, Mocking y Estrategia
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:
| Nivel | Que se testea | Herramienta |
|---|---|---|
| Unitario | Entidades y value objects | #[test] nativo |
| Mocking | Use cases con repositorio falso | mockall |
| Integracion | Handlers HTTP + BD | Pendiente |
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:
- Zero overhead: el codigo de test no se incluye en el binario de produccion
- Acceso a items privados:
use super::*importa todo del modulo padre, incluyendo funciones privadas - Colocation: los tests viven junto al codigo que testean, no en archivos separados
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:
cfg_attr(test, ...): solo aplica el atributo en compilacion de testmockall::automock: genera una structMockCardRepositorycon metodosexpect_*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:
- Crear el mock con
MockCardRepository::new() - Configurar expectativas con
expect_*().returning(...) - Inyectar el mock en el use case
- 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:
- Tests end-to-end con un navegador headless
- Testear la logica de signals aislada de la vista
Glosario
| Termino | Definicion |
|---|---|
| #[cfg(test)] | Atributo que compila codigo solo durante cargo test |
| mockall | Crate 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 + Sync | Bounds de Rust que permiten enviar datos entre threads |
| tower::ServiceExt | Extension trait para testear servicios HTTP de tower/axum |
← Anterior: Drag & Drop y Gateway HTTP | Siguiente: Decisiones y Trade-offs →