Portada del artículo «Mocks y stubs en pruebas unitarias: cuándo usar cada uno para aislar dependencias».

Mocks y stubs en pruebas unitarias: cuándo usar cada uno para aislar dependencias

Una prueba unitaria debería comprobar una pieza de comportamiento sin depender de que una base de datos esté disponible, una API responda o un servicio de correo funcione. Sin embargo, muchas unidades necesitan colaborar con otros componentes para realizar su trabajo.

Los mocks y stubs permiten sustituir esas dependencias por dobles controlados. La diferencia no está tanto en la herramienta como en la pregunta que responde la prueba: ¿qué resultado produce la unidad ante una respuesta concreta o qué colaboración solicita a otra pieza? Veamos cómo distinguirlos con ejemplos en Python y cómo evitar que el aislamiento genere una falsa sensación de seguridad.

Qué significa aislar una dependencia

Una dependencia es un componente que el código bajo prueba necesita para funcionar: un repositorio, un cliente HTTP, un reloj o un sistema de notificaciones, por ejemplo. Aislarla consiste en reemplazarla durante una prueba para controlar su comportamiento o comprobar cómo se utiliza.

Esto permite reproducir situaciones como una respuesta vacía o un fallo de conexión sin provocar el problema en un sistema real. También ayuda a que la prueba se centre en la lógica de la unidad, sin que los datos compartidos o un error externo determinen el resultado.

El límite es importante: una prueba con dobles no demuestra que la integración real funcione. Para eso hacen falta pruebas adicionales con los componentes auténticos o con un entorno representativo.

Qué es un stub

Un stub proporciona respuestas preparadas para ejecutar la unidad en un escenario concreto. Su papel principal es controlar la información que recibe el código.

Por ejemplo, si un servicio consulta si hay existencias, un stub puede responder que sí. La prueba utiliza esa respuesta para comprobar el resultado del servicio; no necesita verificar cómo se realizó la consulta.

Un stub resulta útil para:

  • Simular respuestas exitosas, vacías o con errores.
  • Probar distintas ramas de una decisión.
  • Evitar llamadas reales a recursos externos.
  • Mantener las entradas previsibles y fáciles de entender.

La característica decisiva es su función en la prueba: aportar una respuesta controlada.

Qué es un mock

Un mock permite verificar interacciones: si se llamó a una operación, con qué argumentos y, cuando importa, cuántas veces. Ayuda a comprobar una colaboración que forma parte del comportamiento esperado.

Por ejemplo, al confirmar un pedido puede ser importante que el sistema solicite una notificación. La prueba verifica esa solicitud sin enviar un mensaje real.

La distinción práctica es esta:

  • Un stub prepara una situación para observar qué resultado produce la unidad.
  • Un mock ayuda a comprobar que la unidad interactúa con una dependencia como se espera.

Una misma herramienta puede servir para ambos fines. Incluso un mismo objeto puede proporcionar respuestas y registrar llamadas: su papel depende de cómo se use en la prueba.

Ejemplo práctico: confirmar un pedido

Imagina un procesador que consulta el inventario y solicita una notificación solo cuando el producto está disponible. El ejemplo utiliza Python y su biblioteca estándar. Las dependencias se reciben en el constructor, lo que permite sustituirlas:

from unittest import TestCase
from unittest.mock import Mock


class ProcesadorPedido:
    def __init__(self, inventario, notificador):
        self.inventario = inventario
        self.notificador = notificador

    def confirmar(self, producto):
        if not self.inventario.disponible(producto):
            return False
        self.notificador.enviar(producto)
        return True


class InventarioStub:
    def __init__(self, disponible):
        self.respuesta = disponible

    def disponible(self, producto):
        return self.respuesta


class NotificadorInerte:
    def enviar(self, producto):
        pass


class PruebasPedido(TestCase):
    def test_confirma_si_hay_existencias(self):
        pedido = ProcesadorPedido(
            InventarioStub(True), NotificadorInerte()
        )
        self.assertTrue(pedido.confirmar('libro'))

    def test_notifica_al_confirmar(self):
        notificador = Mock()
        pedido = ProcesadorPedido(InventarioStub(True), notificador)

        pedido.confirmar('libro')

        notificador.enviar.assert_called_once_with('libro')

    def test_no_notifica_si_no_hay_existencias(self):
        notificador = Mock()
        pedido = ProcesadorPedido(InventarioStub(False), notificador)

        self.assertFalse(pedido.confirmar('libro'))
        notificador.enviar.assert_not_called()

El stub controla la respuesta

InventarioStub devuelve el valor preparado al construirlo. En la primera prueba proporciona un escenario con existencias y permite comprobar que confirmar devuelve True.

El notificador de esa prueba es un doble inerte: acepta la llamada sin producir efectos, porque esa prueba se concentra en el resultado. La clase es deliberadamente sencilla y no modela reservas ni cambios reales de inventario.

El mock comprueba la interacción

En las otras pruebas, notificador registra las llamadas recibidas. La segunda comprueba que se solicita una notificación con el producto correcto; la tercera, que no se solicita ninguna cuando faltan existencias.

Estas verificaciones tienen sentido porque enviar o no enviar la notificación forma parte del comportamiento definido. No prueban, sin embargo, que llegue un mensaje real: solo que el procesador pidió enviarlo.

Ejemplo de stub: una API con resultados o errores

Supongamos que un servicio consulta un cliente de catálogo. El contrato acordado para ese cliente es devolver una lista o lanzar ConnectionError si no puede conectarse. El servicio debe contar los productos disponibles y propagar el error de conexión:

class Catalogo:
    def __init__(self, cliente):
        self.cliente = cliente

    def contar_disponibles(self):
        productos = self.cliente.listar()
        return sum(1 for p in productos if p['disponible'])


class ClienteStub:
    def __init__(self, respuesta=None, error=None):
        self.respuesta = respuesta
        self.error = error

    def listar(self):
        if self.error is not None:
            raise self.error
        return self.respuesta

Las siguientes pruebas utilizan ese stub para cubrir tres escenarios sin realizar peticiones HTTP:

from unittest import TestCase


class PruebasCatalogo(TestCase):
    def test_cuenta_solo_disponibles(self):
        cliente = ClienteStub(respuesta=[
            {'disponible': True},
            {'disponible': False},
        ])
        self.assertEqual(Catalogo(cliente).contar_disponibles(), 1)

    def test_lista_vacia(self):
        cliente = ClienteStub(respuesta=[])
        self.assertEqual(Catalogo(cliente).contar_disponibles(), 0)

    def test_propaga_error_de_conexion(self):
        cliente = ClienteStub(error=ConnectionError('sin conexión'))
        with self.assertRaises(ConnectionError):
            Catalogo(cliente).contar_disponibles()

Aquí no verificamos cuántas veces se llama a listar: el requisito del ejemplo se refiere al resultado y a la propagación del error. Si más adelante cambia la implementación sin alterar ese comportamiento, estas pruebas no deberían fallar por una interacción irrelevante.

El stub tampoco demuestra que la API devuelva realmente esa estructura ni que el cliente traduzca sus errores a ConnectionError. Esas suposiciones deben contrastarse con el contrato del cliente y pruebas de integración.

Ejemplo de mock: solicitar un pago

En un pago importa tanto el resultado como la instrucción enviada a la pasarela. Este ejemplo recibe el importe en unidades mínimas de la moneda, como céntimos, para no depender de cálculos con números decimales:

from unittest import TestCase
from unittest.mock import Mock


class ServicioPago:
    def __init__(self, pasarela):
        self.pasarela = pasarela

    def cobrar(self, importe, moneda):
        if importe <= 0:
            raise ValueError('El importe debe ser positivo')
        return self.pasarela.cobrar(importe, moneda)


class PruebasPago(TestCase):
    def test_solicita_el_cobro_correcto(self):
        pasarela = Mock()
        pasarela.cobrar.return_value = 'aceptado'
        servicio = ServicioPago(pasarela)

        resultado = servicio.cobrar(2500, 'EUR')

        self.assertEqual(resultado, 'aceptado')
        pasarela.cobrar.assert_called_once_with(2500, 'EUR')

    def test_rechaza_importe_invalido_sin_cobrar(self):
        pasarela = Mock()
        servicio = ServicioPago(pasarela)

        with self.assertRaises(ValueError):
            servicio.cobrar(0, 'EUR')
        pasarela.cobrar.assert_not_called()

En la primera prueba, return_value prepara la respuesta de la pasarela: el objeto también cumple una función de stub. La aserción sobre la llamada verifica la colaboración relevante: solicitar un único cobro con el importe y la moneda esperados. La segunda prueba comprueba que la validación impide solicitar un cobro inválido.

Este mock no confirma que la pasarela acepte esos argumentos, procese el pago o evite cobros duplicados. La prueba verifica únicamente el comportamiento del servicio frente al doble.

Cuándo elegir cada uno

Empieza por la pregunta que debe responder la prueba, no por la biblioteca instalada.

Usa un stub para controlar lo que recibe la unidad

Elige un stub cuando necesites preparar una condición y evaluar su consecuencia. Por ejemplo:

  • Una API devuelve una lista vacía y el servicio debe responder sin resultados.
  • Un repositorio indica que no existe un usuario y la aplicación debe rechazar el acceso.
  • Un reloj devuelve una fecha fija para comprobar si una suscripción ha caducado.

En estos casos suele importar un valor, un error o un cambio de estado observable. Verificar cada llamada realizada para obtenerlo puede añadir detalles sin aumentar la confianza.

Usa un mock para comprobar una colaboración relevante

Elige un mock cuando la interacción sea parte del requisito. Por ejemplo:

  • Solicitar un pago con el importe y la moneda calculados.
  • No enviar una notificación cuando falla una validación.
  • Pedir al repositorio que guarde un registro generado por la unidad.

Comprueba el número de llamadas o su orden solo cuando tenga importancia para el comportamiento. Una prueba que exige un orden irrelevante puede fallar tras una refactorización válida, aunque el resultado siga siendo correcto.

Otros dobles que conviene conocer

Los términos se usan de forma distinta entre equipos y bibliotecas, pero estas categorías ayudan a comunicar la intención:

  • Dummy: se pasa para satisfacer una dependencia exigida, pero no se utiliza en el escenario probado.
  • Fake: tiene una implementación funcional simplificada, como un repositorio en memoria. Puede servir para varias pruebas, aunque no reproduzca todas las características del sistema real.
  • Spy: registra información sobre las llamadas para inspeccionarla después. Según la herramienta, puede envolver un objeto real o comportarse como otro doble.

Por ejemplo, un repositorio en memoria permite guardar y recuperar un pedido durante una prueba. Es más funcional que un stub con una respuesta fija, pero no garantiza que una base de datos real aplique las mismas restricciones o gestione igual las transacciones.

Errores habituales al aislar dependencias

Simular todo por defecto. Sustituir cada colaborador aumenta la configuración y puede ocultar problemas entre componentes rápidos y seguros de ejecutar juntos. Aísla lo que introduce incertidumbre, efectos externos o complejidad innecesaria.

Comprobar detalles internos. Verificar métodos privados o secuencias sin relevancia para el requisito acopla la prueba a la implementación. Prioriza el resultado público y las interacciones que realmente importan.

Crear respuestas poco realistas. Un stub que devuelve estructuras imposibles o errores que el componente real no produce puede dar confianza falsa. Mantén sus respuestas alineadas con el contrato de la dependencia y revísalas cuando cambie.

Confundir una llamada con un efecto real. Que se haya invocado un cliente de correo no demuestra que el mensaje se haya entregado. Añade pruebas de integración para validar la comunicación y los formatos cuando corresponda.

Olvidar los casos negativos. Además del camino exitoso, comprueba respuestas vacías, errores y condiciones que deben impedir una acción. Selecciona las combinaciones que representen riesgos y reglas del negocio.

Confiar demasiado en mocks permisivos. En Python, un Mock sin restricciones puede aceptar atributos que la dependencia real no tiene. Cuando sea útil, spec o autospec ayudan a limitarlo a la interfaz esperada. Aun así, no sustituyen la comprobación de que el componente real cumple su contrato.

Una guía rápida para escribir la prueba

  1. Define el comportamiento esperado desde fuera de la unidad.
  2. Identifica qué dependencias necesitas controlar o sustituir para evitar efectos reales.
  3. Prepara stubs con respuestas acordes con el contrato para cada escenario.
  4. Usa mocks para las interacciones que formen parte del requisito, incluidas las que no deben ocurrir.
  5. Comprueba el resultado o estado observable sin imponer detalles internos innecesarios.
  6. Complementa las pruebas unitarias con pruebas de integración que validen las conexiones reales.

Si una prueba necesita una configuración excesiva, quizá la unidad tenga demasiadas responsabilidades o dependa de componentes difíciles de sustituir. Esa dificultad es una señal para revisar el diseño, no una prueba definitiva de que sea incorrecto.

Conclusión

Los stubs controlan las respuestas que recibe el código; los mocks permiten verificar colaboraciones relevantes. Ambos pueden convivir en una misma prueba, como ocurre al preparar la respuesta de una pasarela y comprobar la solicitud de cobro.

En tu próxima prueba, identifica primero el comportamiento esperado y usa el doble más sencillo que permita comprobarlo. Después, revisa qué suposiciones sobre las dependencias quedan sin validar y cúbrelas con pruebas de integración apropiadas.