study.plynte.com
@franguh
Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)

Guía de respuestas y soluciones razonadas: Módulo 04 — Diseño y pruebas#

Esta guía técnica de referencia presenta las soluciones canónicas, el proceso de razonamiento paso a paso bajo la disciplina TDD (RED arrow GREEN arrow REFACTOR), el análisis exhaustivo de arquitectura y límites, las respuestas rigurosas a las preguntas de diagnóstico socrático y los contraejemplos de errores de diseño para las lecciones 13 a 15 del curso.

🛡️ Información Crítica

Intenta resolver primero cada ejercicio por tu cuenta y registrar la evidencia empírica en tu terminal antes de consultar esta guía. El aprendizaje profundo en diseño de software y arquitectura no se adquiere memorizando código final terminado, sino experimentando la fricción del cambio, diagnosticando advertencias del compilador y razonando sobre los límites de tus abstracciones.

Para profundizar en cualquier término técnico utilizado en estas soluciones, consulta el glosario técnico.


Lección 13 — Detectar olores y refactorizar con evidencia#

1. Razonamiento paso a paso y estrategia TDD (RED arrow GREEN arrow REFACTOR)#

Declaración formal del contrato y regla de negocio:#

Se requiere calcular la tarifa de envío de una orden comercial bajo las siguientes especificaciones:

  • Unidad monetaria: Todos los importes se expresan en centavos enteros (u32) para garantizar aritmética exacta en hardware y evitar los errores de redondeo de punto flotante binario (IEEE 754).
  • Tarifa base estándar: $250$ centavos ($2.50 dólares).
  • Umbral de gratuidad: $1000$ centavos ($10.00 dólares).
  • Regla de modalidad:
  • Si la orden se recoge en la tienda física (Pickup), el costo de envío es siempre $0$ centavos, independientemente del subtotal.
  • Si la orden se envía a domicilio (Standard Delivery), el costo es $0$ centavos si el subtotal es mayor o igual a $1000$ centavos, y $250$ centavos si el subtotal es estrictamente menor a $1000$ centavos.

Matriz de partición de equivalencias y valores límite:#

  • Caso 1: Subtotal 0, Recogida (Pickup) arrow Retorna 0.
  • Caso 2: Subtotal 0, Envío a domicilio (Standard) arrow Retorna 250 (subtotal mínimo bajo el umbral).
  • Caso 3: Subtotal 999, Envío a domicilio (Standard) arrow Retorna 250 (justo un centavo antes de la frontera de gratuidad).
  • Caso 4: Subtotal 1000, Envío a domicilio (Standard) arrow Retorna 0 (exactamente en el umbral de gratuidad).
  • Caso 5: Subtotal 1001, Envío a domicilio (Standard) arrow Retorna 0 (justo un centavo sobre el umbral).
  • Caso 6: Subtotal 1000, Recogida (Pickup) arrow Retorna 0 (coincidencia de ambas condiciones de gratuidad).

Ciclo TDD:#

  1. Fase RED: Escribimos la función inicial con el olor típico de argumento bandera: shipping_fee_legacy(subtotal_cents: u32, is_pickup: bool) -> u32 devolviendo 9999 deliberadamente. Añadimos las pruebas para los 6 casos. Ejecutamos rustc --test smell_solution.rs y comprobamos que fallan reportando la discrepancia esperada.
  2. Fase GREEN: Implementamos la lógica mínima para satisfacer las aserciones. Verificamos que todas las pruebas pasen (test result: ok.). Esta suite es nuestra línea base en verde (GREEN baseline).
  3. Fase REFACTOR: - Detectamos el olor: en los puntos de llamada, escribir shipping_fee_legacy(0, true) padece de ceguera booleana (boolean blindness). - Introducimos un tipo enumerado explícito DeliveryMode { Pickup, StandardDelivery }. - Reemplazamos la función con shipping_fee(subtotal_cents: u32, mode: DeliveryMode) -> u32. - Extraemos constantes de dominio descriptivas (FREE_SHIPPING_THRESHOLD_CENTS, STANDARD_FEE_CENTS). - Las pruebas se actualizan para usar el enum y pasan al 100%, demostrando preservación estricta del contrato.

2. Implementación canónica completa y ejecutable (Lección 13)#

Puedes guardar el siguiente código como lesson13_solution.rs y verificarlo directamente con rustc --test lesson13_solution.rs:

RUST
// ==========================================
// BEFORE REFACTORING (Code Smell: Flag Argument)
// ==========================================

pub fn shipping_fee_legacy(subtotal_cents: u32, is_pickup: bool) -> u32 {
    const FREE_THRESHOLD_CENTS: u32 = 1000;
    const STANDARD_FEE_CENTS: u32 = 250;

    if is_pickup || subtotal_cents >= FREE_THRESHOLD_CENTS {
        0
    } else {
        STANDARD_FEE_CENTS
    }
}

// ==========================================
// AFTER REFACTORING (Clean, Expressive Domain)
// ==========================================

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryMode {
    Pickup,
    StandardDelivery,
}

pub fn shipping_fee(subtotal_cents: u32, mode: DeliveryMode) -> u32 {
    const FREE_SHIPPING_THRESHOLD_CENTS: u32 = 1000;
    const STANDARD_FEE_CENTS: u32 = 250;

    match mode {
        DeliveryMode::Pickup => 0,
        DeliveryMode::StandardDelivery => {
            if subtotal_cents >= FREE_SHIPPING_THRESHOLD_CENTS {
                0
            } else {
                STANDARD_FEE_CENTS
            }
        }
    }
}

// ==========================================
// AUTOMATED TEST SUITE (Equivalence Matrix)
// ==========================================

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

    #[test]
    fn legacy_implementation_satisfies_contract() {
        assert_eq!(shipping_fee_legacy(0, true), 0);
        assert_eq!(shipping_fee_legacy(0, false), 250);
        assert_eq!(shipping_fee_legacy(999, false), 250);
        assert_eq!(shipping_fee_legacy(1000, false), 0);
        assert_eq!(shipping_fee_legacy(1001, false), 0);
        assert_eq!(shipping_fee_legacy(1000, true), 0);
    }

    #[test]
    fn refactored_implementation_preserves_contract_across_all_boundaries() {
        // Boundary 1: Zero cents, pickup
        assert_eq!(shipping_fee(0, DeliveryMode::Pickup), 0);

        // Boundary 2: Zero cents, standard delivery
        assert_eq!(shipping_fee(0, DeliveryMode::StandardDelivery), 250);

        // Boundary 3: Exactly 1 cent below free shipping threshold
        assert_eq!(shipping_fee(999, DeliveryMode::StandardDelivery), 250);

        // Boundary 4: Exactly at free shipping threshold
        assert_eq!(shipping_fee(1000, DeliveryMode::StandardDelivery), 0);

        // Boundary 5: 1 cent above free shipping threshold
        assert_eq!(shipping_fee(1001, DeliveryMode::StandardDelivery), 0);

        // Boundary 6: Pickup at threshold
        assert_eq!(shipping_fee(1000, DeliveryMode::Pickup), 0);
    }
}

3. Respuestas a las preguntas de comprensión profunda (Lección 13)#

Pregunta 1: La regla de los dos sombreros y el peligro de refactorizar en rojo#

  • Violación del principio: La definición formal de refactorización exige preservar el comportamiento observable. Si modificas la estructura mientras el sistema produce un fallo o mientras una prueba está fallando (fase RED), estás intentando reparar el motor y rediseñar el chasis simultáneamente. No hay forma de aislar si una prueba subsiguiente falla por el bug original o por un error introducido durante la refactorización.
  • Dificultad de diagnóstico: Si una prueba de integración falla intermitentemente más tarde, el equipo no sabe si el problema proviene del nuevo comportamiento para subtotal $0$, de un error en los constructores renombrados o de una regresión en el contrato.
  • Protocolo en tres fases:
  1. Fase 1 (Reproducción y Blindaje): Escribir una prueba unitaria específica que reproduzca el fallo con subtotal $0$ (la prueba falla: estado RED).
  2. Fase 2 (Corrección mínima): Corregir el algoritmo para hacer pasar la prueba con el menor cambio posible hasta que toda la suite quede en verde (estado GREEN baseline).
  3. Fase 3 (Refactorización): Ahora que el comportamiento correcto está protegido al 100%, ponerse el sombrero de refactorización y renombrar las tres clases, verificando en cada pequeño paso que la suite continúe en verde.

Pregunta 2: Deduplicación accidental vs conascencia semántica#

  • Clasificación: Es una duplicación accidental. La función de penalización por factura vencida pertenece al subdominio contable/financiero, mientras que la penalización por intentos de login fallidos pertenece al subdominio de seguridad informática. Coinciden sintácticamente hoy de forma casual (ambas usan 8 líneas de lógica similar), pero no comparten la misma ontología ni el mismo motivo de cambio.
  • Consecuencia futura: Si en seis meses seguridad adopta una curva exponencial para mitigar ataques de fuerza bruta (brute-force attacks), el desarrollador que modifique la función compartida alterará involuntariamente el cálculo legal de intereses moratorios de la empresa, desencadenando demandas fiscales o quejas de clientes.
  • Costo arquitectónico: Se introdujo un acoplamiento patológico entre dos contextos delimitados (Bounded Contexts) que debían evolucionar de forma totalmente independiente.

Pregunta 3: Ceguera booleana (Boolean Blindness) y combinatorias ilegales#

  • Problema cognitivo: Al leer create_user("Alice", "alice@example.com", false, true, true), el revisor no puede saber qué hace cada booleano posicional sin abrir el archivo de implementación. Es sumamente fácil invertir dos valores sin que el compilador emita advertencia alguna.
  • Espacio de estados: Tres booleanos modelan 2³ = 8 combinaciones posibles.
  • Representación de estados ilegales: Si el negocio prohíbe que un usuario inactivo reciba correos de bienvenida, pasar (false, true, false) o (false, true, true) representa un estado inválido que la firma de tipos permite libremente.
  • Rediseño en Rust:
RUST
  pub enum WelcomeEmailPolicy {
      SendWelcomeEmail,
      DoNotSendEmail,
  }

  pub enum AccountStatus {
      Active { welcome_email: WelcomeEmailPolicy },
      Inactive, // Notice: Inactive cannot physically carry a welcome email policy!
  }

  pub enum UserRole {
      StandardUser,
      Administrator,
  }

  pub fn create_user(
      name: String,
      email: String,
      role: UserRole,
      status: AccountStatus,
  ) { /* ... */ }

Con este diseño, es sintácticamente imposible construir un usuario inactivo que solicite un correo de bienvenida: el estado ilegal se vuelve irrepresentable en tiempo de compilación (Make Illegal States Unrepresentable).

Pregunta 4: Diagnóstico diferencial: Divergent Change vs Shotgun Surgery#

  • Escenario X (Editar 14 controladores, 6 serializadores y 4 vistas al cambiar una fecha): Corresponde a *Cirugía de escopeta (Shotgun Surgery)*. Un único cambio conceptual de negocio se dispersa en decenas de archivos. Viola el principio de encapsulamiento. Solución: encapsular la fecha en un tipo Newtype de dominio con su propio serializador centralizado.
  • Escenario Y (Editar UserManager.rs por OAuth, comisiones de ventas o plantillas de correo): Corresponde a *Cambio divergente (Divergent Change). Un único archivo es modificado continuamente por razones completamente desconectadas. Viola el Principio de Responsabilidad Única (Single Responsibility Principle*). Solución: dividir UserManager en módulos especializados (AuthenticationService, SalesCommissionPolicy, NotificationEngine).

Pregunta 5: Optimización prematura, intuición y evidencia de profiling#

  • Por qué no es refactorización: La refactorización mejora la estructura interna preservando las garantías de seguridad. Introducir unsafe destruye las garantías formales de ausencia de comportamientos indefinidos (Undefined Behavior) del compilador de Rust.
  • Riesgos: Abre la puerta a lecturas fuera de límites de memoria (out-of-bounds memory reads), desreferencias de punteros colgantes o corrupción silenciosa del montón (heap).
  • Criterio de rechazo: En una lista de 50 elementos que cabe íntegramente en la memoria caché L1 de la CPU (32 KB), el tiempo de iteración ronda los 10 nanosegundos. Si el tiempo total de la solicitud web es de 25 milisegundos (dominado por la red o la base de datos), optimizar 5 nanosegundos representa una ganancia del 0.00002%, mientras que el costo en riesgo de colapso y mantenimiento es inmenso. Sin un informe de perf o criterion que demuestre que esa función representa un porcentaje medible del tiempo total, el cambio debe rechazarse tajantemente.

4. Errores comunes y contraejemplos (Lección 13)#

Error común: Abstracción prematura con Newtype sin invariantes#

RUST
// Antipatrón: crear un Newtype para cada primitiva sin ninguna invariante que proteger
pub struct SubtotalCents(pub u32);
pub struct ShippingCents(pub u32);
pub struct DiscountCents(pub u32);

Problema: Si estos tipos no validan nada (un SubtotalCents(10) puede ser cualquier número) y solo envuelven un u32 público, añaden una sobrecarga sintáctica enorme obligando al programador a escribir .0 en cada línea de código sin aportar ninguna seguridad real. Introduce el patrón Newtype únicamente cuando el tipo proteja una regla de negocio (ej. Cents que rechaza desbordamientos o Email que valida arroba y dominio).


Lección 14 — Límites, dependencias y efectos visibles#

1. Razonamiento paso a paso: separación de fronteras y clasificación#

Clasificación formal del ciclo de vida de una petición:#

  1. Deserializar bytes a JSON y validar formato sintáctico: Pertenece al Adaptador de Transporte (Capa de Infraestructura / Web). Es responsabilidad de Actix Web o Axum rechazar JSONs mal formados con un código HTTP 400 Bad Request.
  2. Normalizar título (trim) y verificar longitud según el negocio: Pertenece a la Regla de Dominio Puro. Es una función referencialmente transparente que se prueba en memoria sin dependencias.
  3. Consultar duplicados e insertar en almacenamiento: Es un Efecto Secundario. Pertenece a la frontera de persistencia y se abstrae mediante un Puerto (trait TaskRepository), permitiendo inyectar un adaptador SQL en producción o un doble en memoria (fake) en pruebas.
  4. Construir respuesta HTTP (201 Created / 409 Conflict): Pertenece al Adaptador de Transporte. Traduce el resultado del servicio de dominio a la semántica del protocolo HTTP.

El riesgo TOCTOU (Time-of-Check to Time-of-Use) en límites concurrentes:#

Una regla pura en memoria como can_add(current, limit) verifica la capacidad lógica en un instante puntual. Sin embargo, en un entorno web concurrente donde 10 solicitudes paralelas cruzan la verificación simultáneamente antes de que ninguna haya terminado de insertar, la verificación en memoria queda obsoleta de inmediato.

  • Solución arquitectónica: La función pura define la regla, pero el adaptador de persistencia debe aplicar mecanismos atómicos de concurrencia en la base de datos:
  • Bloqueo pesimista: SELECT count(*) FROM tasks FOR UPDATE;
  • Restricciones declarativas de integridad: CONSTRAINT max_capacity_check CHECK (...) o CREATE UNIQUE INDEX ...

2. Implementación canónica completa y ejecutable (Lección 14)#

Puedes guardar este código como lesson14_solution.rs y verificarlo con rustc --test lesson14_solution.rs:

RUST
// ==========================================
// 1. PURE DOMAIN (Rules & Invariants)
// ==========================================

#[derive(Debug, PartialEq, Eq)]
pub enum ValidationError {
    EmptyTitle,
}

#[derive(Debug, PartialEq, Eq)]
pub enum CreateTaskError {
    Validation(ValidationError),
    DuplicateTitle,
    StorageFailure(String),
}

pub fn can_add(current_count: usize, limit: usize) -> bool {
    current_count < limit
}

pub fn validate_task_title(raw: &str) -> Result<String, ValidationError> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        Err(ValidationError::EmptyTitle)
    } else {
        Ok(trimmed.to_string())
    }
}

// ==========================================
// 2. PERSISTENCE PORT (Trait Contract)
// ==========================================

pub trait TaskRepository {
    fn exists_by_title(&self, title: &str) -> Result<bool, String>;
    fn save(&mut self, title: String) -> Result<(), String>;
}

// ==========================================
// 3. APPLICATION SERVICE (Boundary Coordinator)
// ==========================================

pub fn create_task<R: TaskRepository>(
    repo: &mut R,
    raw_title: &str,
) -> Result<(), CreateTaskError> {
    // Step 1: Pure domain validation
    let valid_title = validate_task_title(raw_title)
        .map_err(CreateTaskError::Validation)?;

    // Step 2: Query persistence port for duplicates
    match repo.exists_by_title(&valid_title) {
        Ok(true) => Err(CreateTaskError::DuplicateTitle),
        Ok(false) => {
            // Step 3: Persist through port
            repo.save(valid_title)
                .map_err(CreateTaskError::StorageFailure)
        }
        // Step 4: Handle infrastructure failure without confusing with "not found"
        Err(err) => Err(CreateTaskError::StorageFailure(err)),
    }
}

// ==========================================
// 4. TEST DOUBLE ADAPTER (In-Memory Fake)
// ==========================================

#[derive(Default)]
pub struct InMemoryTaskRepository {
    pub tasks: Vec<String>,
    pub simulate_outage: bool,
}

impl TaskRepository for InMemoryTaskRepository {
    fn exists_by_title(&self, title: &str) -> Result<bool, String> {
        if self.simulate_outage {
            return Err("Database connection pool exhausted".to_string());
        }
        Ok(self.tasks.iter().any(|t| t == title))
    }

    fn save(&mut self, title: String) -> Result<(), String> {
        if self.simulate_outage {
            return Err("Database connection pool exhausted".to_string());
        }
        self.tasks.push(title);
        Ok(())
    }
}

// ==========================================
// 5. TEST SUITE (Fast, Deterministic, Isolated)
// ==========================================

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

    #[test]
    fn pure_rule_can_add_evaluates_boundary_limits() {
        assert!(can_add(0, 5));
        assert!(can_add(4, 5));
        assert!(!can_add(5, 5));
        assert!(!can_add(6, 5));
    }

    #[test]
    fn title_validation_rejects_whitespace_without_storage_interaction() {
        let mut repo = InMemoryTaskRepository {
            simulate_outage: true, // Even if DB is broken, validation fails first
            ..Default::default()
        };
        let result = create_task(&mut repo, "   \t  ");
        assert_eq!(result, Err(CreateTaskError::Validation(ValidationError::EmptyTitle)));
        assert!(repo.tasks.is_empty());
    }

    #[test]
    fn creates_task_successfully_when_unique() {
        let mut repo = InMemoryTaskRepository::default();
        let result = create_task(&mut repo, "Architectural Boundaries");
        assert_eq!(result, Ok(()));
        assert_eq!(repo.tasks, vec!["Architectural Boundaries"]);
    }

    #[test]
    fn detects_and_rejects_duplicate_titles() {
        let mut repo = InMemoryTaskRepository {
            tasks: vec!["Architectural Boundaries".to_string()],
            ..Default::default()
        };
        let result = create_task(&mut repo, "Architectural Boundaries");
        assert_eq!(result, Err(CreateTaskError::DuplicateTitle));
        assert_eq!(repo.tasks.len(), 1);
    }

    #[test]
    fn preserves_infrastructure_error_when_database_fails() {
        let mut repo = InMemoryTaskRepository {
            simulate_outage: true,
            ..Default::default()
        };
        let result = create_task(&mut repo, "Critical Update");
        assert_eq!(
            result,
            Err(CreateTaskError::StorageFailure("Database connection pool exhausted".to_string()))
        );
    }
}

3. Respuestas a las preguntas de comprensión profunda (Lección 14)#

Pregunta 1: Dependencia de compilación vs Flujo de datos en tiempo de ejecución#

  • El mecanismo de inversión: Rust permite invertir dependencias gracias a los traits. El dominio define el trait TaskRepository. El módulo de persistencia postgres_adapter.rs contiene impl TaskRepository for PostgresStore. Por tanto, el archivo postgres_adapter.rs contiene use crate::domain::TaskRepository;, pero el archivo de dominio no tiene ninguna referencia a PostgreSQL.
  • En tiempo de ejecución: La CPU ejecuta código de Actix Web arrow llama al dominio arrow el dominio invoca el método del trait arrow la CPU salta a las instrucciones del adaptador de PostgreSQL.
  • Impacto de actualización: Si PostgreSQL o SQLx se actualizan a una nueva versión, únicamente se recompila el adaptador de infraestructura. El módulo de dominio no se recompila ni se ve afectado en lo más mínimo, logrando total independencia tecnológica.

Pregunta 2: Fuga de abstracciones en tipos de error#

  • Violación de frontera: Si el puerto define -> Result<(), reqwest::Error>, el dominio se acopla irrevocablemente a una biblioteca HTTP concreta (reqwest).
  • Consecuencia de sustitución: Si se migra a gRPC o a un fake en memoria, los nuevos componentes no pueden producir un reqwest::Error sin fabricar objetos de red artificiales.
  • Diseño idiomático:
RUST
  #[derive(Debug, PartialEq, Eq)]
  pub enum PaymentError {
      CardDeclined(String),
      InsufficientFunds,
      GatewayTimeout,
      InfrastructureError(String),
  }

  pub trait PaymentGateway {
      fn charge(&self, amount_cents: u32) -> Result<(), PaymentError>;
  }

Pregunta 3: La trampa de la función pura y la condición de carrera TOCTOU#

  • *Entrelazado de hilos (Interleaving):*
  1. Estado inicial en base de datos: 9 tareas activas (límite: 10).
  2. Hilo 1 lee count = 9.
  3. Hilo 2 lee count = 9.
  4. Hilo 1 ejecuta can_add(9, 10) arrow obtiene true.
  5. Hilo 2 ejecuta can_add(9, 10) arrow obtiene true.
  6. Hilo 1 inserta la tarea 10.
  7. Hilo 2 inserta la tarea 11.
  • Separación de responsabilidades: La función pura can_add decide si una operación es válida dado un estado estático conocido. El adaptador de base de datos es responsable de garantizar que la lectura y la escritura ocurran dentro de una transacción con aislamiento adecuado o protegidas por una restricción de comprobación (check constraint) en el motor relacional.

Pregunta 4: Despacho estático (impl Trait) vs Despacho dinámico (dyn Trait)#

  • Enfoque A (<R: TaskRepository>): El compilador genera código de máquina especializado para cada tipo concreto mediante monomorfización. La llamada a método es un salto directo a una dirección fija en memoria de instrucciones, lo que permite al optimizador LLVM aplicar inlining (incrustar el cuerpo del método en el sitio de llamada) eliminando por completo el sobrecoste de invocación.
  • Enfoque B (&dyn TaskRepository): El compilador genera un puntero gordo (fat pointer) con dos punteros de 64 bits: uno a los datos en memoria y otro a la tabla virtual (vtable). La llamada requiere desreferenciar la vtable en tiempo de ejecución, lo que añade una pequeña indirección e impide la mayoría de las optimizaciones de inlining.
  • Cuándo es obligatorio el Enfoque B: Cuando necesitas almacenar adaptadores heterogéneos en una misma colección en tiempo de ejecución (por ejemplo, Vec<Box<dyn NotificationSender>> que contiene un emisor de email y un emisor de SMS mezclados), o cuando se busca reducir el tamaño del binario final evitando la duplicación de código monomorfizado.

Pregunta 5: El costo de la abstracción prematura#

  • Evaluación objetiva: En una aplicación de consola de un solo uso que lee un CSV de 20 líneas, inventar 6 capas de puertos y adaptadores no resuelve ningún problema técnico real. No existen múltiples fuentes de datos, no hay latencia de red que simular y las pruebas pueden leer directamente una cadena en memoria con std::io::Cursor.
  • Costo: Aumenta la fricción mental, triplica las líneas de código y dificulta que otro desarrollador comprenda el flujo central.
  • Solución cohesiva: Una función pura que recibe un &str con el contenido del CSV y devuelve un Result<Vec<Record>, ParseError>. Cero traits, cero adaptadores, 100% testeable.

4. Errores comunes y contraejemplos (Lección 14)#

Error común: Confundir desconexión con inexistencia#

RUST
// Código defectuoso:
let exists = match repo.exists_by_title(title) {
    Ok(found) => found,
    Err(_) => false, // ¡PELIGRO MORTAL! Asume que si la base falló, el título no existe
};

Problema: Si la base de datos sufrió un timeout temporal, el código asume que el título no existe e intenta ejecutar una inserción que fallará o corromperá el estado. Trata siempre los errores de infraestructura como StorageFailure distinguibles de un resultado booleano exitoso.


Lección 15 — Pruebas, fallos y depuración reproducible#

1. Razonamiento paso a paso: caracterización, depuración científica y tiempo determinista#

Protocolo de 6 pasos de depuración científica:#

  1. Observación formal: La función insert_sorted con entrada items = [2, 5], index = 2, value = 7 devuelve Err(IndexOutOfBounds) en lugar de insertar al final.
  2. Hipótesis falsable: La comprobación de límites utiliza index >= items.len() en lugar de index > items.len(), considerando erróneamente que el índice igual a la longitud actual está fuera de rango.
  3. Reproducción mínima: Crear un vector de dos elementos y llamar a la función en el índice 2.
  4. Fase RED (Prueba de regresión): Escribir una prueba que afirme que la llamada debe retornar Ok(()) y dejar la colección como [2, 5, 7]. Ejecutar la prueba contra el código defectuoso y verificar que falla exactamente por la causa prevista.
  5. Fase GREEN (Corrección mínima): Modificar la condición a if index > items.len(). Ejecutar las pruebas y verificar que pasen.
  6. Fase REFACTOR: Enriquecer el tipo de error con contexto (InsertError::IndexOutOfBounds { index, len }) para facilitar diagnósticos futuros.

La frontera de inserción en colecciones indexadas:#

En un vector con N elementos (longitud N):

  • Los índices de elementos existentes son 0, 1, …, N-1.
  • Las posiciones válidas para insertar un nuevo elemento van desde $0$ (al inicio) hasta N (al final). Por tanto, un índice de inserción es legal si y solo si index ≤ N.
  • Todo índice estrictamente mayor a N (index > N) viola el principio de contigüidad y debe ser rechazado sin causar pánico en tiempo de ejecución.

2. Implementaciones canónicas completas y ejecutables (Lección 15)#

Puedes guardar este código como lesson15_solution.rs y verificarlo con rustc --test lesson15_solution.rs:

RUST
// ==========================================
// 1. LEGACY SYSTEM (Buggy Boundary Check)
// ==========================================

pub fn insert_sorted_legacy(items: &mut Vec<i32>, index: usize, value: i32) -> Result<(), &'static str> {
    // Off-by-one boundary bug: rejects legal insertion at index == items.len()
    if index >= items.len() && !items.is_empty() {
        return Err("index out of bounds");
    }
    if items.is_empty() && index > 0 {
        return Err("index out of bounds");
    }
    items.insert(index, value);
    Ok(())
}

// ==========================================
// 2. FIXED SYSTEM (Disciplined Error Contract)
// ==========================================

#[derive(Debug, PartialEq, Eq)]
pub enum InsertError {
    IndexOutOfBounds { index: usize, len: usize },
}

pub fn insert_sorted(items: &mut Vec<i32>, index: usize, value: i32) -> Result<(), InsertError> {
    // Correct boundary condition: insertion is valid up to index == items.len()
    if index > items.len() {
        return Err(InsertError::IndexOutOfBounds {
            index,
            len: items.len(),
        });
    }
    items.insert(index, value);
    Ok(())
}

// ==========================================
// 3. DETERMINISTIC TIME INJECTION
// ==========================================

pub trait Clock {
    fn now_seconds(&self) -> u64;
}

pub struct FrozenClock {
    pub current_time: u64,
}

impl Clock for FrozenClock {
    fn now_seconds(&self) -> u64 {
        self.current_time
    }
}

pub struct SessionManager<C: Clock> {
    clock: C,
    timeout_seconds: u64,
}

impl<C: Clock> SessionManager<C> {
    pub fn new(clock: C, timeout_seconds: u64) -> Self {
        Self {
            clock,
            timeout_seconds,
        }
    }

    pub fn is_session_expired(&self, session_created_at: u64) -> bool {
        let now = self.clock.now_seconds();
        now.saturating_sub(session_created_at) >= self.timeout_seconds
    }
}

// ==========================================
// 4. AUTOMATED TESTS & CHARACTERIZATION
// ==========================================

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

    #[test]
    fn legacy_characterization_test_locks_in_quirk_before_fixing() {
        let mut list = vec![2, 5];
        // Index 0 works
        assert!(insert_sorted_legacy(&mut list, 0, 1).is_ok());

        // Index 1 works
        let mut list2 = vec![2, 5];
        assert!(insert_sorted_legacy(&mut list2, 1, 3).is_ok());

        // Quirky defect captured: index 2 (at the end) was rejected in legacy
        let mut list3 = vec![2, 5];
        assert_eq!(insert_sorted_legacy(&mut list3, 2, 7), Err("index out of bounds"));
    }

    #[test]
    fn fixed_insert_sorted_satisfies_all_equivalence_partitions() {
        // Partition 1: Empty list, index 0
        let mut empty = Vec::new();
        assert_eq!(insert_sorted(&mut empty, 0, 42), Ok(()));
        assert_eq!(empty, vec![42]);

        // Partition 2: Non-empty list, index 0 (insert at head)
        let mut list_head = vec![2, 5];
        assert_eq!(insert_sorted(&mut list_head, 0, 1), Ok(()));
        assert_eq!(list_head, vec![1, 2, 5]);

        // Partition 3: Non-empty list, index 1 (insert in middle)
        let mut list_mid = vec![2, 5];
        assert_eq!(insert_sorted(&mut list_mid, 1, 3), Ok(()));
        assert_eq!(list_mid, vec![2, 3, 5]);

        // Partition 4: Non-empty list, index 2 == len (insert at tail) -> FIXED!
        let mut list_tail = vec![2, 5];
        assert_eq!(insert_sorted(&mut list_tail, 2, 7), Ok(()));
        assert_eq!(list_tail, vec![2, 5, 7]);

        // Partition 5: Index strictly greater than length (out of bounds)
        let mut list_invalid = vec![2, 5];
        assert_eq!(
            insert_sorted(&mut list_invalid, 3, 9),
            Err(InsertError::IndexOutOfBounds { index: 3, len: 2 })
        );
        assert_eq!(list_invalid, vec![2, 5]); // State was preserved without corruption
    }

    #[test]
    fn session_expiration_evaluated_deterministically_without_sleeping() {
        // Virtual time frozen at t = 5,000 seconds
        let clock = FrozenClock { current_time: 5_000 };
        let manager = SessionManager::new(clock, 900); // 15-minute timeout (900 seconds)

        // Session created 899 seconds ago -> Still active
        assert!(!manager.is_session_expired(5_000 - 899));

        // Session created exactly 900 seconds ago -> Expired
        assert!(manager.is_session_expired(5_000 - 900));

        // Session created 901 seconds ago -> Expired
        assert!(manager.is_session_expired(5_000 - 901));
    }
}

3. Respuestas a las preguntas de comprensión profunda (Lección 15)#

Pregunta 1: Verificación de estado vs Verificación de interacción en refactorizaciones#

  • Comportamiento ante la refactorización:
  • La Suite A fallará por completo. Al haber consolidado los pasos en memoria, las llamadas a step_1() y step_2() ya no ocurren. Aunque el resultado contable sea impecable, la prueba con mocks arrojará errores de expectativas incumplidas (unexpected interaction).
  • La Suite B permanecerá 100% en verde. Dado que solo verifica el saldo resultante en el libro contable, es indiferente a si el cálculo se realizó en tres pasos o en uno solo.
  • Impacto psicológico: Cuando los desarrolladores descubren que cada mejora de diseño o limpieza de código rompe decenas de pruebas con mocks a pesar de que el software funciona perfectamente, desarrollan aversión a refactorizar. Las pruebas pasan de ser una red de seguridad a convertirse en un ancla de resistencia al cambio (test fragility).

Pregunta 2: La falacia de la cobertura de líneas del 100% y las pruebas vacuas#

  • Contraejemplo:
RUST
  pub fn compute_ratio(a: u32, b: u32) -> u32 {
      a / b
  }

  #[test]
  fn test_compute_ratio() {
      assert_eq!(compute_ratio(10, 2), 5); // 100% line & branch coverage!
  }

Si en producción un usuario envía b = 0, el programa colapsa con pánico por división entre cero. La línea fue visitada por la prueba, pero la propiedad de robustez ante entradas no válidas jamás fue evaluada.

  • Línea visitada vs Propiedad semántica: La cobertura mide únicamente si el puntero de instrucciones de la CPU pasó por esa posición de memoria durante el test. No mide si los casos límite fueron explorados, si las aserciones son sólidas ni si se verificaron las invariantes del dominio.

Pregunta 3: Depuración científica vs Ensayo y error (Shotgun Debugging)#

  • Enfoque de ensayo y error: El programador cambia configuraciones aleatorias de JWT, altera tiempos de expiración sin medir, reinicia servidores y prueba con tokens generados a mano en Postman esperando que "se arregle". Si funciona una vez, da el bug por resuelto, sin saber si volverá a fallar bajo carga.
  • Enfoque científico:
  1. Capturar los registros de auditoría y extraer la cabecera exacta de la petición que falló con 401.
  2. Formular una hipótesis falsable: "El token es rechazado porque el servidor valida el campo nbf (Not Before) contra el reloj local y existe un desfase de reloj (clock drift) de 2 segundos entre el emisor y el receptor".
  3. Aislar la reproducción mínima creando un token sintético con nbf 1 segundo en el futuro.
  4. Escribir una prueba automatizada que reproduzca el rechazo (fase RED).
  5. Aplicar la corrección mínima: añadir un margen de tolerancia (leeway) de 5 segundos en la validación temporal.
  6. Confirmar que la prueba pase (fase GREEN) y añadirla a la suite de regresión permanente.

Pregunta 4: Pruebas de caracterización (Golden Master) en sistemas sin especificación#

  • El peligro de asumir requisitos: En un sistema con 20 años de historia, muchas "rarezas" del código en realidad representan parches fiscales críticos para clientes corporativos. Si reescribes la lógica asumiendo lo que dice la teoría tributaria pura, romperás la facturación de clientes existentes.
  • Estrategia Golden Master:
  1. Seleccionar un lote representativo de 10,000 transacciones históricas anonimizadas.
  2. Ejecutar el binario en C existente con ese lote y guardar las salidas exactas generadas en un archivo de referencia (Golden Master).
  3. Ejecutar la nueva implementación en Rust con el mismo lote de entradas y comparar byte a byte la salida contra el archivo de referencia.
  4. Cualquier divergencia revela una incompatibilidad que debe ser investigada conscientemente antes de autorizar el reemplazo.

Pregunta 5: Erradicación del no-determinismo y control del tiempo#

  • Problemas de thread::sleep(900): Una sola prueba tardaría 15 minutos en ejecutarse; correr la suite completa en cada pull request sería inviable. Además, cualquier sobrecarga en el runner de CI provocaría fallos intermitentes.
  • Solución con FrozenClock:
RUST
  let clock = FrozenClock { current_time: 10_000 };
  let service = AccountLockoutService::new(clock);
  service.register_failed_attempt();
  service.register_failed_attempt();
  service.register_failed_attempt(); // Account is locked

  // Advance virtual time by 901 seconds instantly in memory:
  clock.current_time += 901;

  assert!(service.can_attempt_login());

La prueba ejecuta una simple suma entera de 64 bits en un registro de CPU, evaluando la condición de desbloqueo en menos de 5 nanosegundos sin ninguna llamada bloqueante al sistema operativo.


4. Errores comunes y contraejemplos (Lección 15)#

Antipatrón: Pruebas tautológicas que solo prueban el arnés de prueba#

RUST
// Código defectuoso:
#[test]
fn test_task_creation_tautological() {
    let mut fake_repo = InMemoryTaskRepository::default();
    fake_repo.tasks.push("Test Task".to_string());
    
    // Antipattern: asserting on what we literally just pushed ourselves!
    // This doesn't test create_task(), it only tests that Vec::push works!
    assert_eq!(fake_repo.tasks.len(), 1);
}

Problema: La prueba no invoca la lógica de aplicación (create_task) ni verifica el contrato del caso de uso. Pasa siempre, otorga una falsa sensación de seguridad y no detecta si create_task contiene un defecto fatal.


Cómo reconocer un diseño sólido y una suite de pruebas profesional#

Al concluir este módulo de diseño y pruebas, un ingeniero de software distingue el código maduro mediante las siguientes características observables:

  1. Separación implacable de responsabilidades: Las funciones puras contienen las matemáticas del negocio; los adaptadores contienen el I/O y las bibliotecas externas.
  2. Inversión de dependencias sin dogmas: Se utilizan traits donde existe una necesidad real de desacoplamiento o simulación determinista; no se inventan interfaces vacías para funciones triviales.
  3. Manejo riguroso de errores: Los errores de dominio son enumeraciones tipadas y exhaustivas; los fallos de infraestructura se conservan con claridad y nunca se enmascaran como "datos no encontrados".
  4. Pruebas veloces y deterministas: Las pruebas unitarias se ejecutan en milisegundos en memoria sin pausas temporales (thread::sleep); las pruebas de integración verifican contratos reales con recursos externos; las aserciones validan estados observables y no detalles privados de implementación.

Estado de Lectura Marca tu progreso local en tu navegador (iPad / Móvil / Desktop)