Ir al contenido
  1. Posts/

Todo CLI en Rust 3.1. Estrategia de pruebas y deuda técnica explícita

·15 mins
Rafael Fernandez
Autor
Rafael Fernandez
Matemáticas, programación y cosas de la vida
Tabla de contenido
Todo CLI en Rust sin humo - Este artículo es parte de una serie.
Parte 4: Este artículo

Seguimos con la serie.

En el capítulo anterior abrimos la despensa, definimos el contrato TaskRepository y montamos dos implementaciones: un HashMap en memoria y un archivo JSON a disco. La cocina tiene ingredientes frescos y conservas bien etiquetadas. Pero, ¿cómo sabemos que las conservas no se han echado a perder entre usos? ¿Cómo verificamos que el sistema se comporta como prometimos?

Este capítulo es el control de calidad de la cocina. No vamos a probar cada ingrediente por separado en un laboratorio estéril, vamos a preparar platos reales con cada implementación y comprobar que el resultado es el esperado. Es testing por comportamiento, no por implementación.

todo-cli-en-rust-31-estrategia-de-pruebas-y-deuda-técnica-explícita-img-9.png

Código de referencia:

Estrategia de pruebas: testear comportamiento, no implementación
#

Antes de ver los tests, vale la pena explicar qué tipo de tests escribimos y por qué.

En un proyecto con arquitectura hexagonal, los tests de persistencia pueden caer en una trampa habitual: testear detalles de implementación. “¿Se insertó en el HashMap?” “¿Se escribió el JSON con la indentación correcta?” “¿Se llamó a fs::write exactamente una vez?” Esos tests son frágiles, se rompen cuando refactorizas sin cambiar el comportamiento.

Nosotros testeamos comportamiento observable del contrato. Las preguntas que responden nuestros tests son:

  • Si guardo una tarea y la busco por ID, ¿la encuentro?
  • Si guardo una tarea modificada con el mismo ID, ¿se actualiza?
  • Si borro una tarea existente, ¿devuelve true?
  • Si borro una que no existe, ¿devuelve false sin error?
  • Si filtro por estado, ¿obtengo solo las tareas correctas?

Estas preguntas son independientes de la implementación. Funcionan igual para un HashMap, un archivo JSON, una base de datos SQLite o un servicio REST remoto. Y eso las hace resistentes a refactorizaciones internas.

El patrón es siempre el mismo: Arrange → Act → Assert contra la interfaz pública del trait TaskRepository, nunca contra métodos internos del adapter.

Tests del InMemoryTaskRepository
#

El in-memory es el adapter más simple. Sus tests validan que el contrato funciona correctamente en el caso base, sin disco, sin latencia, sin efectos secundarios. Cada test empieza con un repositorio vacío y utiliza exclusivamente métodos del trait.

Código completo: in_memory_task_repository.rs#tests

El helper new_task
#

fn new_task(title: &str) -> Task {
    Task::new(title.to_string()).expect("task should be created")
}

Todas las baterías de tests comparten un helper mínimo: crear una tarea válida con un título dado. El .expect() es intencional, si Task::new falla aquí, es un bug en el dominio que debe estallar inmediatamente, no silenciarse con un unwrap() anónimo. El mensaje "task should be created" deja claro qué rompió si el test falla.

save_and_find_by_id_returns_task
#

#[test]
fn save_and_find_by_id_returns_task() {
    let mut repo = InMemoryTaskRepository::new();
    let task = new_task("learn rust");
    let id = task.task_id();

    repo.save(task).expect("save should succeed");

    let found = repo.find_by_id(id).expect("find should succeed");
    assert!(found.is_some());
    let found = found.expect("task should exist");
    assert_eq!(found.task_id(), id);
    assert_eq!(found.title(), "learn rust");
}

El test más fundamental: guarda y recupera. Verifica que el task_id se preserva y que el título no se pierde ni muta en el camino. Si este test falla, nada más funciona, es la base sobre la que se apoyan todas las operaciones.

Fíjate en la secuencia de unwraps: primero expect en save, luego expect en find_by_id (el RepoResult), luego expect en el Option. Cada uno con un mensaje que diagnostica exactamente dónde falló la cadena.

save_is_upsert_when_same_id_is_saved_again
#

#[test]
fn save_is_upsert_when_same_id_is_saved_again() {
    let mut repo = InMemoryTaskRepository::new();
    let original = new_task("pay rent");
    let id = original.task_id();
    repo.save(original.clone()).expect("save should succeed");

    let updated = original
        .mark_done()
        .expect("status transition should succeed");
    repo.save(updated).expect("save should succeed");

    let all = repo.list(TaskQuery::All).expect("list should succeed");
    assert_eq!(all.len(), 1);

    let found = repo.find_by_id(id).expect("find should succeed");
    let found = found.expect("task should exist");
    assert_eq!(found.status(), TaskStatus::Done);
}

Este test valida la semántica de upsert del contrato. Cuando guardas una tarea con el mismo ID que ya existe, no se duplica, se reemplaza. Es exactamente lo que necesita el dominio inmutable: mark_done() produce una nueva instancia con el mismo ID pero estado Done, y el repositorio la sustituye.

Observa el .clone() en original.clone(). Es necesario porque save toma ownership (la firma es fn save(&mut self, task: Task)), pero luego necesitamos original para llamar a mark_done(). El clone es el coste de la inmutabilidad con ownership transfer, un coste explícito y rastreable.

Las aserciones verifican dos cosas: (1) solo hay una tarea en total (all.len() == 1), y (2) su estado es Done. Si hubiera duplicación, len() sería 2. Si el upsert no reemplazara, el status seguiría en Todo.

delete_returns_true_for_existing_task
#

#[test]
fn delete_returns_true_for_existing_task() {
    let mut repo = InMemoryTaskRepository::new();
    let task = new_task("task to delete");
    let id = task.task_id();
    repo.save(task).expect("save should succeed");

    let deleted = repo.delete(id).expect("delete should succeed");
    assert!(deleted);

    let found = repo.find_by_id(id).expect("find should succeed");
    assert!(found.is_none());
}

Dos aserciones complementarias: delete devuelve true y la tarea ya no es localizable. Si solo verificaras el bool sin buscar después, un bug donde delete devuelve true pero no borra pasaría desapercibido.

delete_returns_false_for_non_existing_task
#

#[test]
fn delete_returns_false_for_non_existing_task() {
    let mut repo = InMemoryTaskRepository::new();
    let id = new_task("temporary").task_id();

    let deleted = repo.delete(id).expect("delete should succeed");
    assert!(!deleted);
}

Verifica la idempotencia: borrar algo que no existe no es un error, devuelve false. Observa el truco para obtener un UUID válido pero que no está en el repositorio: crear una tarea (que genera un Uuid::new_v4() internamente) y usar solo su ID, sin guardarla.

list_all_returns_all_tasks
#

#[test]
fn list_all_returns_all_tasks() {
    let mut repo = InMemoryTaskRepository::new();
    repo.save(new_task("task 1")).expect("save should succeed");
    repo.save(new_task("task 2")).expect("save should succeed");

    let all = repo.list(TaskQuery::All).expect("list should succeed");
    assert_eq!(all.len(), 2);
}

Directo. Guarda dos, lista todas, espera dos. No verifica orden porque HashMap no garantiza orden de iteración, y el contrato tampoco lo promete.

list_by_status_filters_tasks
#

#[test]
fn list_by_status_filters_tasks() {
    let mut repo = InMemoryTaskRepository::new();
    let todo = new_task("todo task");
    let done = new_task("done task")
        .mark_done()
        .expect("status transition should succeed");

    repo.save(todo).expect("save should succeed");
    repo.save(done).expect("save should succeed");

    let done_tasks = repo
        .list(TaskQuery::ByStatus(TaskStatus::Done))
        .expect("list should succeed");
    assert_eq!(done_tasks.len(), 1);
    assert_eq!(done_tasks[0].status(), TaskStatus::Done);

    let todo_tasks = repo
        .list(TaskQuery::ByStatus(TaskStatus::Todo))
        .expect("list should succeed");
    assert_eq!(todo_tasks.len(), 1);
    assert_eq!(todo_tasks[0].status(), TaskStatus::Todo);
}

Verifica ambas ramas del filtro en el mismo test: una tarea Todo, una Done, filtra por cada estado y comprueba que solo aparece la correcta. El doble assert (len + status) es intencional: len == 1 podría ser cualquiera de las dos; status() == Done confirma que es la correcta.

Tests del JsonFileTaskRepository
#

Aquí las cosas cambian sustancialmente. Ya no trabajamos en memoria pura: hay filesystem real, serialización JSON, y el riesgo de que un test deje residuos que contaminen al siguiente.

Código completo: json_file_task_repository.rs#tests

Aislamiento con tempdir
#

Cada test del JSON adapter sigue el mismo patrón de setup:

let temp = tempdir().expect("temp dir should be created");
let file_path = temp.path().join("tasks.json");
let mut repo = JsonFileTaskRepository::using(file_path.clone());

Tres líneas que resuelven tres problemas:

  1. tempdir() (del crate tempfile): crea un directorio temporal único por test. Cuando la variable temp se destruye al final del test (drop), el directorio se borra automáticamente. Sin limpieza manual, sin riesgo de residuos entre tests.

  2. file_path = temp.path().join("tasks.json"): construye una ruta dentro del directorio temporal. El archivo no existe todavía, se crea la primera vez que save escribe.

  3. JsonFileTaskRepository::using(file_path): usa el constructor alternativo que acepta una ruta arbitraria, en vez de new() que resuelve la ruta de plataforma con directories. Este constructor existe exclusivamente para tests, es una seam de testing limpia que permite inyectar la ubicación sin modificar la lógica del adapter.

El .clone() en file_path.clone() es necesario porque using() toma ownership del PathBuf, pero algunos tests necesitan la ruta después para verificar existencia de archivo o crear un segundo repositorio apuntando al mismo fichero.

save_creates_file_and_persists_task
#

#[test]
fn save_creates_file_and_persists_task() {
    let temp = tempdir().expect("temp dir should be created");
    let file_path = temp.path().join("tasks.json");
    let mut repo = JsonFileTaskRepository::using(file_path.clone());

    let task = new_task("learn rust");
    let id = task.task_id();

    repo.save(task).expect("save should succeed");

    assert!(file_path.exists());
    let found = repo.find_by_id(id).expect("find should succeed");
    assert!(found.is_some());
}

Este test verifica algo que el in-memory no puede verificar: que save crea el archivo en disco. assert!(file_path.exists()) es una aserción de infraestructura pura, estamos testeando el side effect del adapter, no la lógica de negocio. Esto es exactamente lo que justifica tener tests separados por adapter: cada implementación tiene comportamientos observables distintos.

list_all_returns_tasks_persisted_on_disk
#

#[test]
fn list_all_returns_tasks_persisted_on_disk() {
    let temp = tempdir().expect("temp dir should be created");
    let file_path = temp.path().join("tasks.json");
    let mut repo = JsonFileTaskRepository::using(file_path);

    repo.save(new_task("task 1")).expect("save should succeed");
    repo.save(new_task("task 2")).expect("save should succeed");

    let all = repo.list(TaskQuery::All).expect("list should succeed");
    assert_eq!(all.len(), 2);
}

Funcionalmente idéntico al test del in-memory, pero implícitamente verifica que la serialización/deserialización es transparente: los datos pasan por serde_json::to_string al guardar y serde_json::from_str al listar, y el resultado sigue siendo correcto. Si hay un bug en los derives Serialize/Deserialize de Task, este test lo atrapa.

delete_returns_true_for_existing_task_and_false_otherwise
#

#[test]
fn delete_returns_true_for_existing_task_and_false_otherwise() {
    let temp = tempdir().expect("temp dir should be created");
    let file_path = temp.path().join("tasks.json");
    let mut repo = JsonFileTaskRepository::using(file_path);

    let task = new_task("task to delete");
    let id = task.task_id();
    repo.save(task).expect("save should succeed");

    let deleted = repo.delete(id).expect("delete should succeed");
    assert!(deleted);

    let deleted_again = repo.delete(id).expect("delete should succeed");
    assert!(!deleted_again);
}

Observa que este test combina dos escenarios en uno: borra existente (true), borra de nuevo (false). En el in-memory teníamos dos tests separados. ¿Por qué combinarlos aquí? Porque cada test del JSON adapter tiene un coste de setup mayor (crear directorio temporal, escribir archivo). Combinar escenarios relacionados reduce ese overhead sin sacrificar legibilidad. Es un trade-off pragmático, no una regla.

El segundo delete también verifica implícitamente que el archivo se actualizó correctamente: si retain no escribió el archivo sin la tarea, el segundo delete la encontraría y devolvería true.

data_persists_between_repository_instances
#

#[test]
fn data_persists_between_repository_instances() {
    let temp = tempdir().expect("temp dir should be created");
    let file_path = temp.path().join("tasks.json");

    let mut writer = JsonFileTaskRepository::using(file_path.clone());
    let task = new_task("persist me");
    let id = task.task_id();
    writer.save(task).expect("save should succeed");

    let reader = JsonFileTaskRepository::using(file_path);
    let found = reader.find_by_id(id).expect("find should succeed");
    assert!(found.is_some());
}

Este es el test más revelador de la serie. Crea dos instancias del repositorio apuntando al mismo archivo: una escribe, otra lee. Verifica que los datos sobreviven al destruir y reconstruir el repositorio.

¿Por qué importa? Porque el in-memory pierde datos cuando se destruye, pero el JSON adapter debe sobrevivir entre ejecuciones del proceso. Este test simula exactamente eso: la primera ejecución guarda una tarea, la segunda ejecución (nueva instancia del repo) la encuentra. Si el JSON se escribe en un formato que no se puede releer, o si TasksFile no deserializa correctamente, este test falla.

Fíjate en los nombres de las variables: writer y reader. No son repo1 y repo2. Los nombres comunican la intención del test, no la mecánica.

invalid_json_returns_error
#

#[test]
fn invalid_json_returns_error() {
    let temp = tempdir().expect("temp dir should be created");
    let file_path = temp.path().join("tasks.json");
    fs::write(&file_path, "{invalid json").expect("invalid test payload should be written");

    let repo = JsonFileTaskRepository::using(file_path);
    let result = repo.list(TaskQuery::All);

    assert!(result.is_err());
}

Este test hace algo que ningún test de lógica de negocio haría: corrompe el archivo de datos a propósito. Escribe JSON inválido directamente con fs::write, luego intenta leer con el repositorio y verifica que se obtiene Err, no un panic.

Es un test de resiliencia del adapter. Cubre el caso de que el archivo se corrompa por un crash del sistema, una edición manual incorrecta, o un bug en la serialización. La respuesta correcta es un error explícito, nunca un unwrap() que mate el proceso ni un silencioso “no hay datos”.

Por qué no hay tests compartidos entre adapters
#

Una pregunta legítima: si ambos adapters implementan el mismo trait, ¿por qué no hay una batería de tests genérica que se ejecute contra cualquier impl TaskRepository?

Se podría. Algo así:

fn test_save_and_find<R: TaskRepository>(repo: &mut R) {
    let task = new_task("test");
    let id = task.task_id();
    repo.save(task).expect("save should succeed");
    let found = repo.find_by_id(id).expect("find should succeed");
    assert!(found.is_some());
}

La razón de no hacerlo es pragmática, no técnica:

  1. Cada adapter tiene comportamientos observables distintos. El JSON adapter necesita verificar que el archivo se crea (file_path.exists()), que los datos persisten entre instancias, que JSON corrupto produce error. El in-memory no tiene ninguno de estos escenarios. Tests compartidos cubrirían solo la intersección, el denominador común, y perderían precisamente los escenarios más valiosos.

  2. El setup es diferente. El in-memory se instancia con InMemoryTaskRepository::new(). El JSON necesita tempdir() + using(path). Un factory genérico para abstractor el setup añade complejidad sin ganar cobertura significativa.

  3. La duplicación es mínima. Son 6 tests por adapter, con firmas de pocas líneas cada uno. La duplicación real (el patrón “crea repo, guarda tarea, busca tarea, assert”) es tan pequeña que la abstracción sería más código que la repetición.

  4. La legibilidad es máxima. Cada test se lee de arriba a abajo sin saltar a funciones genéricas. Cuando un test falla, sabes exactamente qué adapter y qué escenario rompió, sin indirecciones.

Si tuviéramos 10 adapters con 30 tests cada uno, la decisión sería diferente. Con 2 adapters y 12 tests en total, la duplicación controlada es más mantenible que la abstracción prematura.

Deuda técnica explícita
#

No vamos a fingir que este sistema está terminado ni que cada decisión es definitiva. Hay tres puntos de deuda técnica que elegimos conscientemente y documentamos en vez de esconder.

1. RepoError genérico con String
#

#[derive(Debug, Error)]
pub enum RepoError {
    #[error("internal error: {error}")]
    InternalError { error: String },
}

Como vimos en el Post 3, RepoError tiene una sola variante con un campo String libre. Esto significa que si quieres distinguir programáticamente “el archivo no existe” de “el JSON está corrupto” de “no hay permisos de escritura”, no puedes: todos son InternalError con texto diferente.

¿Por qué es aceptable hoy? Porque el único consumer de RepoError es la cadena de propagación de errores hasta main.rs, donde se imprime el mensaje y se sale con código 1. Nadie hace match sobre variantes de RepoError para tomar decisiones de negocio.

¿Cuándo dejaría de ser aceptable? Cuando un caso de uso necesite reaccionar de forma diferente según el tipo de error de persistencia. Por ejemplo: “si el archivo no existe, crearlo automáticamente; si hay permisos insuficientes, sugerir sudo”. En ese momento, InternalError { error: String } sería insuficiente y habría que introducir variantes como IoError(std::io::Error), ParseError(serde_json::Error), PermissionDenied(PathBuf).

El coste de resolverlo: definir variantes tipadas en RepoError, mapear cada error específico en cada adapter, y posiblemente importar tipos de std::io y serde_json en el puerto de salida (lo cual tensiona la frontera de abstracción). No es difícil, pero no aporta valor para el uso actual.

2. Sin file locking en JsonFileTaskRepository
#

El adapter lee el archivo completo, lo modifica en memoria y lo escribe de vuelta. Si dos procesos ejecutan todo add "X" y todo add "Y" simultáneamente:

  1. Proceso A lee tasks.json (tiene 3 tareas).
  2. Proceso B lee tasks.json (tiene 3 tareas).
  3. Proceso A escribe tasks.json con 4 tareas (la suya).
  4. Proceso B escribe tasks.json con 4 tareas (la suya), sobrescribe la de A.

Resultado: una tarea se pierde. Es un race condition clásico de read-modify-write sin exclusión mutua.

¿Por qué es aceptable hoy? Porque un CLI de tareas personales se ejecuta por un solo usuario, desde un solo terminal, una operación a la vez. La probabilidad de dos escrituras simultáneas es virtualmente cero.

¿Cuándo dejaría de ser aceptable? Si la herramienta se usara en un entorno multi-proceso (un daemon que sincroniza tareas, un script de CI que modifica tareas en paralelo, o una integración con un editor que hace autosave). En ese momento necesitarías file locking (flock en Unix, LockFile en Windows) o un mecanismo de escritura atómica (escribir a un archivo temporal + rename).

3. Sin versionado ni migración de JSON
#

El formato del archivo tasks.json es implícito: es la serialización directa de TasksFile { tasks: Vec<Task> }. Si mañana añades un campo priority: Priority a Task, el JSON existente no tendrá ese campo. Dependiendo de cómo configures serde, puede fallar o puede usar un valor por defecto silencioso.

¿Por qué es aceptable hoy? Porque el proyecto está en fase de exploración. El formato del archivo puede cambiar entre versiones sin preocuparnos por datos legacy de usuarios reales.

¿Cuándo dejaría de ser aceptable? Cuando haya usuarios reales con archivos de datos que quieras preservar entre actualizaciones del binario. En ese momento necesitarías:

  • Un campo version: u32 en TasksFile (por eso el struct wrapper, como vimos en el Post 3).
  • Una función de migración migrate(old: TasksFileV1) -> TasksFileV2.
  • O alternativamente, usar #[serde(default)] estratégicamente para campos nuevos con valores por defecto razonables.

Lo que los tests no cubren (y está bien)
#

Ninguna batería de tests cubre el 100% de los escenarios. Es más valioso saber qué no cubrimos y por qué, que pretender cobertura total:

  • Tests de rendimiento. No medimos cuánto tarda leer/escribir un archivo con 10,000 tareas. Para una herramienta personal con decenas de tareas, no es relevante.
  • Tests de concurrencia. No verificamos el race condition del file locking. Lo documentamos como deuda técnica explícita (punto 2 arriba).
  • Tests de integración end-to-end. No ejecutamos el binario compilado con Command::new() para verificar que todo add "X" && todo list funciona como se espera desde la shell. Es un nivel de testing que añadiríamos si la herramienta se distribuyera como paquete.
  • Tests del constructor new(). El constructor de producción usa ProjectDirs::from() que resuelve rutas de plataforma. No lo testeamos porque depende del entorno de ejecución. Por eso existe using(), para que los tests no dependan de la configuración del sistema.

Complemento con historial del repo
#

Si quieres ver cómo se añadieron los tests al repositorio:

Se ve claramente el patrón: implementar primero, testear después. No TDD estricto, pero sí tests que endurecen la implementación y protegen contra regresiones futuras.

Del control de calidad al emplatado
#

En este capítulo hemos abierto cada conserva de la despensa, la hemos inspeccionado y la hemos devuelto al estante con un sello de aprobado. Los tests no prueban que el código es perfecto, prueban que se comporta como prometimos. Y la deuda técnica no es un secreto sucio escondido bajo la alfombra, es una lista explícita de decisiones que podemos tomar cuando el proyecto lo justifique.

Con la cocina equipada (arquitectura), los ingredientes frescos (dominio), las conservas verificadas (persistencia testeada) y el inventario documentado (deuda técnica), es hora de subir a la superficie y presentar el plato: construir la capa que el usuario realmente toca. En el siguiente capítulo emplatamos con clap, diseñando un CLI con parsing tipado, subcomandos como enums y salida dual para humanos y máquinas.

¡Nos vemos en la cocina!

Todo CLI en Rust sin humo - Este artículo es parte de una serie.
Parte 4: Este artículo