Saltar al contenido
Módulo 5 de 7Intermedio

Testing con test doubles

La recompensa de la arquitectura: sustituir los adaptadores por dummies, fakes, stubs, spies y mocks para probar ConfirmarPedido en milisegundos, sin base de datos ni servidor de correo.

En el módulo 1 citamos la promesa original de Cockburn: que la aplicación pudiera desarrollarse y probarse “in isolation from its eventual run-time devices and databases”. Llevamos tres módulos preparando el terreno —puertos como contratos, adaptadores intercambiables, todo el cableado concentrado en el composition root— y este es el módulo donde por fin se cobra la recompensa. Si elegir adaptadores es una decisión de arranque, un test no es más que otro arranque: uno que, en vez de Postgres y SMTP, ensambla el caso de uso con impostores hechos a la medida. Esos impostores tienen nombre, apellido y taxonomía.

El vocabulario: cinco tipos de impostor

La terminología la acuñó Gerard Meszaros en xUnit Test Patterns y la popularizó Martin Fowler en su artículo Test Double. El término paraguas es test double (doble de pruebas, como los dobles de cine):

“Test Double is a generic term for any case where you replace a production object for testing purposes.”

Bajo ese paraguas, Meszaros distingue cinco variedades. Con las definiciones textuales de Fowler y su traducción a nuestra cafetería, recorre el espectro de menos a más lógica de verificación:

De menos a más lógica de verificación

Dummy
"objects are passed around but never actually used. Usually they are just used to fill parameter lists."Un NotificadorDummy que se pasa al constructor solo porque la firma lo exige, en un test donde nunca se notifica nada.
Fake
"objects actually have working implementations, but usually take some shortcut which makes them not suitable for production."Un PedidoRepositoryEnMemoria con un HashMap: guarda y busca de verdad, pero lo pierde todo al apagar el proceso.
Stub
"provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in."Un repositorio que siempre devuelve el mismo pedido enlatado, se le pida el id que se le pida.
Spy
"stubs that also record some information based on how they were called."Un notificador que anota en un Vec cada confirmación que le pidieron enviar, para inspeccionarlo al final.
Mock
"pre-programmed with expectations which form a specification of the calls they are expected to receive."Un MockNotificadorDePedidos al que se le declara «esperarás exactamente una llamada» antes de ejecutar el caso de uso.

La distinción más resbalosa es fake vs. mock, y conviene fijarla ya: el fake tiene lógica que funciona (por eso puedes verificar el estado que dejó), mientras que el mock tiene expectativas (por eso verifica la interacción que ocurrió). Volveremos sobre ella al final — y el minijuego de este módulo existe para que no se te vuelva a escapar.

¿Y por qué esto es “el superpoder” de la arquitectura hexagonal? Porque los dobles no requieren ningún truco de instrumentación: son, simplemente, adaptadores más. Cockburn lo dejó dicho en el artículo original: “Mock objects are the natural test adapter for secondary actors” — el doble implementa el mismo puerto que el adaptador real, y el núcleo no puede notar la diferencia. Recuerda además el módulo 3: el test que conduce al caso de uso es un actor primario como cualquier usuario.

El fake: implementar el puerto en memoria

El puerto no cambia. Es el mismo trait que el adaptador de Postgres implementa en producción:

puertos.rs — el contrato que ambos mundos compartenPuertos
pub trait PedidoRepository {
    fn buscar(&self, id: PedidoId) -> Result<Pedido, ErrorDePedido>;
    fn guardar(&self, pedido: &Pedido) -> Result<(), ErrorDePedido>;
}

pub trait NotificadorDePedidos {
    fn notificar_confirmacion(&self, pedido: &Pedido);
}

El fake es una implementación honesta de ese contrato, con el atajo de guardar en un HashMap en lugar de una tabla:

fakes.rs — un repositorio de verdad, pero en RAMAdaptadores
/// Fake: implementación funcional del puerto que vive en memoria.
pub struct PedidoRepositoryEnMemoria {
    pedidos: RefCell<HashMap<PedidoId, Pedido>>,
}

impl PedidoRepositoryEnMemoria {
    pub fn nuevo() -> Self {
        Self { pedidos: RefCell::new(HashMap::new()) }
    }

    /// Siembra un pedido de prueba y devuelve su id.
    pub fn sembrar(&self, pedido: Pedido) -> PedidoId {
        let id = pedido.id();
        self.pedidos.borrow_mut().insert(id, pedido);
        id
    }
}

impl PedidoRepository for PedidoRepositoryEnMemoria {
    fn buscar(&self, id: PedidoId) -> Result<Pedido, ErrorDePedido> {
        self.pedidos
            .borrow()
            .get(&id)
            .cloned()
            .ok_or(ErrorDePedido::NoEncontrado(id))
    }

    fn guardar(&self, pedido: &Pedido) -> Result<(), ErrorDePedido> {
        self.pedidos.borrow_mut().insert(pedido.id(), pedido.clone());
        Ok(())
    }
}

Nota la etiqueta de capa: el fake es un adaptador (Adaptadores) hecho y derecho — uno cuyo “dispositivo” es la memoria del proceso de test. Para observar el lado de las notificaciones usaremos un spy, la variedad que registra cómo fue llamada:

spies.rs — un notificador que toma notaAdaptadores
/// Spy: registra cada llamada para inspeccionarla al final del test.
pub struct NotificadorSpy {
    avisos: Rc<RefCell<Vec<PedidoId>>>,
}

impl NotificadorDePedidos for NotificadorSpy {
    fn notificar_confirmacion(&self, pedido: &Pedido) {
        self.avisos.borrow_mut().push(pedido.id());
    }
}

El test: sin base de datos, sin servidor de correo

Con ambos dobles listos, el test ensambla el caso de uso exactamente igual que lo hacía el main() del módulo 4 — solo que eligiendo otros adaptadores. El test es, literalmente, un segundo composition root:

confirmar_pedido_test.rs — el test es un actor primarioAdaptadores
#[test]
fn confirmar_guarda_el_pedido_y_avisa_una_vez() {
    // Arrange: mismos puertos, adaptadores de test.
    let repositorio = PedidoRepositoryEnMemoria::nuevo();
    let id = repositorio.sembrar(Pedido::de_prueba_con_una_linea());
    let avisos = Rc::new(RefCell::new(Vec::new()));
    let notificador = NotificadorSpy { avisos: Rc::clone(&avisos) };

    // Act: el test conduce a la aplicación, como lo haría un mesero.
    let caso_de_uso = ConfirmarPedido::new(repositorio, notificador);
    let pedido = caso_de_uso.ejecutar(id).expect("el pedido debía confirmarse");

    // Assert: estado del dominio y conversación con el puerto.
    assert_eq!(pedido.estado(), EstadoDePedido::Confirmado);
    assert_eq!(avisos.borrow().as_slice(), [id]);
}

Ni una cadena de conexión, ni un contenedor que arrancar, ni un sleep esperando a que el puerto 5432 responda. El test ejercita la lógica completa de ConfirmarPedido —buscar, validar, transicionar, guardar, notificar— en milisegundos, y puede correr mil veces seguidas sin tocar la red. Esto es exactamente lo que el módulo 1 no podía hacer.

Míralo como lo que es — una decisión de arranque más. Mismo puerto, mismo núcleo, otro adaptador:

El caso de uso ConfirmarPedido<R, N> habla con el puertoPuertosPedidoRepository y no cambia con ninguna de estas opciones:

PostgresPedidoRepository
Producción: consulta real a la base — milisegundos, con red de por medio.
PedidoRepositoryEnMemoria
Test: HashMap en RAM — microsegundos, cero red.

El mock: expectativas generadas con mockall

Escribir fakes a mano es razonable para puertos pequeños, pero Rust tiene un atajo idiomático: el crate mockall. Como resume la guía Master Hexagonal Architecture in Rust, “The Mockall crate provides the life-saving automock macro to accelerate the process of mocking”: se anota el trait del puerto y mockall genera un MockNotificadorDePedidos configurable.

puertos.rs — el puerto se anota, el mock se generaPuertos
use mockall::automock;

#[cfg_attr(test, automock)]
pub trait NotificadorDePedidos {
    fn notificar_confirmacion(&self, pedido: &Pedido);
}
confirmar_pedido_test.rs — expectativas antes de ejecutarAdaptadores
#[test]
fn notifica_exactamente_una_confirmacion() {
    let repositorio = PedidoRepositoryEnMemoria::nuevo();
    let id = repositorio.sembrar(Pedido::de_prueba_con_una_linea());

    // Mock: la especificación de llamadas se declara ANTES de actuar.
    let mut notificador = MockNotificadorDePedidos::new();
    notificador
        .expect_notificar_confirmacion()
        .times(1)
        .return_const(());

    let caso_de_uso = ConfirmarPedido::new(repositorio, notificador);
    caso_de_uso.ejecutar(id).expect("el pedido debía confirmarse");
    // Al final del test, mockall verifica que la expectativa se cumplió.
}

Observa la diferencia de estilo frente al spy — reproduce ambos ritmos y compara en qué momento aparece la verificación:

Spy: preguntar después

  1. ActuarEjecutas el caso de uso con el spy ya inyectado; él toma nota en silencio.
  2. Preguntar qué pasó (assert)Al final, tú escribes la aserción: ¿cuántos avisos registró el spy?

Mock: declarar antes

  1. Declarar expectativa (times(1))La especificación de llamadas se declara antes de actuar.
  2. ActuarEl caso de uso se ejecuta contra el mock ya programado.
  3. Verificación automáticamockall verifica solo al final: dos llamadas, o ninguna, hacen fallar el test sin que escribas una sola aserción.

¿Cuándo usar cada uno?

No hay dogma, pero sí una brújula útil para la cafetería y para cualquier hexágono:

  • Fake para verificar estado. ¿Quieres comprobar que el pedido quedó guardado como Confirmado? Usa el repositorio en memoria y examina el resultado. Los fakes hacen tests que se leen como historias y toleran refactors internos del caso de uso.
  • Mock (o spy) para verificar interacción. ¿Lo que importa es que se avisó exactamente una vez, con el pedido correcto? Declara la expectativa. Los efectos hacia el exterior —notificar, cobrar, publicar un evento— suelen probarse mejor por interacción, porque su “estado” vive fuera del sistema.
  • Dummy y stub como utilería. Rellenan parámetros y devuelven respuestas enlatadas cuando el escenario no gira alrededor de ese puerto.

Y una advertencia: si un test necesita seis mocks con veinte expectativas para arrancar, el problema ya no es el test — es una señal de que el caso de uso tiene demasiados puertos o de que la frontera está mal trazada. Los dobles hacen visible la calidad del diseño; no la sustituyen.

Minijuego: ¿fake o mock?

Cada tarjeta describe un escenario de test del sistema de la cafetería (o de uno vecino). Decide si el doble descrito es un fake (una implementación funcional con atajo) o un mock (expectativas declaradas que se verifican solas).

Lo que viene

Ya sabemos aislar el núcleo y probarlo a velocidad de memoria. El siguiente paso es mirar dentro de ese núcleo con más aumento: en el próximo módulo lo poblamos con los bloques tácticos de Domain-Driven Design —entidades, value objects y agregados— y decidimos, por fin, qué es exactamente un Pedido por dentro.

Fuentes

Práctica

Practica lo aprendido

Tarjetas de repaso

Evaluación del módulo