24. Dobles de prueba en Python: stubs, fakes y mocks

24.1 Objetivo del tema

Muchas funciones no trabajan solas: consultan una base de datos, envían correos, llaman a una API, leen archivos o usan servicios externos. En una prueba unitaria no siempre queremos ejecutar esas dependencias reales.

En este tema veremos dobles de prueba: objetos simples que reemplazan dependencias reales durante una prueba.

Idea clave: un doble de prueba permite probar una parte del sistema sin depender de servicios lentos, costosos, inestables o difíciles de preparar.

24.2 Tipos de dobles de prueba

Los nombres pueden variar según el autor, pero estos tres aparecen con mucha frecuencia:

  • Stub: devuelve respuestas preparadas para que la prueba pueda avanzar.
  • Fake: tiene una implementación funcional, pero simplificada. Por ejemplo, un repositorio en memoria.
  • Mock: registra cómo fue usado para verificar llamadas, argumentos o cantidad de invocaciones.

Antes de usar herramientas automáticas, conviene entender cómo construir estos dobles manualmente.

24.3 Crear una carpeta de práctica

Crea un proyecto nuevo:

mkdir pytest-dobles-demo
cd pytest-dobles-demo

Si pytest no está instalado en el entorno activo:

python -m pip install pytest

24.4 Crear el módulo a probar

Crea un archivo llamado pedidos.py:

class ServicioPedidos:
    def __init__(self, repositorio, notificador):
        self.repositorio = repositorio
        self.notificador = notificador

    def crear_pedido(self, cliente, productos):
        if not productos:
            raise ValueError("El pedido debe tener productos")

        total = sum(producto["precio"] for producto in productos)
        pedido = {
            "cliente": cliente,
            "productos": productos,
            "total": total,
            "estado": "creado",
        }

        pedido_guardado = self.repositorio.guardar(pedido)
        self.notificador.enviar(
            cliente,
            f"Pedido {pedido_guardado['id']} creado",
        )
        return pedido_guardado

    def buscar_pedido(self, pedido_id):
        pedido = self.repositorio.obtener_por_id(pedido_id)
        if pedido is None:
            return None
        return pedido

    def cancelar_pedido(self, pedido_id):
        pedido = self.repositorio.obtener_por_id(pedido_id)
        if pedido is None:
            raise ValueError("Pedido inexistente")

        pedido["estado"] = "cancelado"
        self.repositorio.guardar(pedido)
        self.notificador.enviar(
            pedido["cliente"],
            f"Pedido {pedido['id']} cancelado",
        )
        return pedido

El servicio no sabe si el repositorio guarda en memoria, en una base de datos o en una API. Solo usa los métodos que necesita.

24.5 Por qué inyectar dependencias

El constructor recibe repositorio y notificador. Esa decisión facilita las pruebas, porque podemos pasar dobles en lugar de objetos reales.

servicio = ServicioPedidos(repositorio_doble, notificador_doble)

Esta técnica se conoce como inyección de dependencias. No requiere frameworks: muchas veces alcanza con pasar objetos por parámetro.

24.6 Stub: devolver una respuesta preparada

Un stub sirve cuando solo necesitamos que una dependencia devuelva un dato conocido:

class RepositorioStub:
    def obtener_por_id(self, pedido_id):
        return {
            "id": pedido_id,
            "cliente": "Ana",
            "productos": [],
            "total": 0,
            "estado": "creado",
        }


class NotificadorStub:
    def enviar(self, destinatario, mensaje):
        pass

La prueba se concentra en el resultado de buscar_pedido:

from pedidos import ServicioPedidos


def test_buscar_pedido_devuelve_pedido():
    servicio = ServicioPedidos(RepositorioStub(), NotificadorStub())

    pedido = servicio.buscar_pedido(10)

    assert pedido["id"] == 10
    assert pedido["cliente"] == "Ana"

24.7 Stub para representar ausencia de datos

También podemos preparar un stub que devuelva None:

class RepositorioVacioStub:
    def obtener_por_id(self, pedido_id):
        return None


def test_buscar_pedido_inexistente_devuelve_none():
    servicio = ServicioPedidos(RepositorioVacioStub(), NotificadorStub())

    assert servicio.buscar_pedido(99) is None

Este doble permite probar un caso borde sin configurar una base de datos vacía.

24.8 Fake: implementación simple en memoria

Un fake tiene comportamiento real, pero más simple que la dependencia de producción. Este repositorio guarda pedidos en una lista:

class RepositorioFake:
    def __init__(self):
        self.pedidos = []
        self.siguiente_id = 1

    def guardar(self, pedido):
        pedido_guardado = pedido.copy()
        if "id" not in pedido_guardado:
            pedido_guardado["id"] = self.siguiente_id
            self.siguiente_id += 1

        self.pedidos = [
            existente
            for existente in self.pedidos
            if existente["id"] != pedido_guardado["id"]
        ]
        self.pedidos.append(pedido_guardado)
        return pedido_guardado

    def obtener_por_id(self, pedido_id):
        for pedido in self.pedidos:
            if pedido["id"] == pedido_id:
                return pedido.copy()
        return None

Este fake permite probar creación, búsqueda y cancelación sin una base de datos real.

24.9 Mock manual: registrar llamadas

Un mock registra cómo fue usado. Este notificador guarda los mensajes enviados:

class NotificadorMock:
    def __init__(self):
        self.mensajes = []

    def enviar(self, destinatario, mensaje):
        self.mensajes.append({
            "destinatario": destinatario,
            "mensaje": mensaje,
        })

Con este doble podemos verificar que el servicio intentó notificar al cliente.

24.10 Probar creación de pedido con fake y mock

Combinamos un fake para guardar datos y un mock para verificar el envío:

def test_crear_pedido_guarda_y_notifica():
    repositorio = RepositorioFake()
    notificador = NotificadorMock()
    servicio = ServicioPedidos(repositorio, notificador)

    pedido = servicio.crear_pedido(
        "Ana",
        [
            {"nombre": "Teclado", "precio": 50000},
            {"nombre": "Mouse", "precio": 20000},
        ],
    )

    assert pedido["id"] == 1
    assert pedido["total"] == 70000
    assert pedido["estado"] == "creado"
    assert notificador.mensajes == [
        {
            "destinatario": "Ana",
            "mensaje": "Pedido 1 creado",
        }
    ]

La prueba verifica estado devuelto y comunicación con la dependencia.

24.11 Probar validaciones sin notificar

Si el pedido no tiene productos, el servicio debe lanzar error y no debería notificar:

import pytest


def test_crear_pedido_sin_productos_lanza_error_y_no_notifica():
    repositorio = RepositorioFake()
    notificador = NotificadorMock()
    servicio = ServicioPedidos(repositorio, notificador)

    with pytest.raises(ValueError):
        servicio.crear_pedido("Ana", [])

    assert repositorio.pedidos == []
    assert notificador.mensajes == []

Esta prueba revisa que el error ocurra antes de guardar o enviar mensajes.

24.12 Probar cancelación de pedido

El fake permite preparar un pedido previo y luego cancelarlo:

def test_cancelar_pedido_cambia_estado_y_notifica():
    repositorio = RepositorioFake()
    notificador = NotificadorMock()
    servicio = ServicioPedidos(repositorio, notificador)
    pedido = repositorio.guardar({
        "cliente": "Luis",
        "productos": [{"nombre": "Monitor", "precio": 120000}],
        "total": 120000,
        "estado": "creado",
    })

    cancelado = servicio.cancelar_pedido(pedido["id"])

    assert cancelado["estado"] == "cancelado"
    assert repositorio.obtener_por_id(pedido["id"])["estado"] == "cancelado"
    assert notificador.mensajes == [
        {
            "destinatario": "Luis",
            "mensaje": "Pedido 1 cancelado",
        }
    ]

24.13 Dobles demasiado complejos

Un doble de prueba debe ser más simple que la dependencia real. Si el fake empieza a tener demasiadas reglas, puede transformarse en otro sistema que también necesitaría pruebas.

Si un doble se vuelve difícil de entender, revisa el diseño del código o usa una herramienta especializada como unittest.mock, que veremos en el próximo tema.

24.14 Archivo completo de pruebas

El archivo test_pedidos.py puede quedar así:

import pytest

from pedidos import ServicioPedidos


class RepositorioStub:
    def obtener_por_id(self, pedido_id):
        return {
            "id": pedido_id,
            "cliente": "Ana",
            "productos": [],
            "total": 0,
            "estado": "creado",
        }


class RepositorioVacioStub:
    def obtener_por_id(self, pedido_id):
        return None


class RepositorioFake:
    def __init__(self):
        self.pedidos = []
        self.siguiente_id = 1

    def guardar(self, pedido):
        pedido_guardado = pedido.copy()
        if "id" not in pedido_guardado:
            pedido_guardado["id"] = self.siguiente_id
            self.siguiente_id += 1

        self.pedidos = [
            existente
            for existente in self.pedidos
            if existente["id"] != pedido_guardado["id"]
        ]
        self.pedidos.append(pedido_guardado)
        return pedido_guardado

    def obtener_por_id(self, pedido_id):
        for pedido in self.pedidos:
            if pedido["id"] == pedido_id:
                return pedido.copy()
        return None


class NotificadorStub:
    def enviar(self, destinatario, mensaje):
        pass


class NotificadorMock:
    def __init__(self):
        self.mensajes = []

    def enviar(self, destinatario, mensaje):
        self.mensajes.append({
            "destinatario": destinatario,
            "mensaje": mensaje,
        })


def test_buscar_pedido_devuelve_pedido():
    servicio = ServicioPedidos(RepositorioStub(), NotificadorStub())

    pedido = servicio.buscar_pedido(10)

    assert pedido["id"] == 10
    assert pedido["cliente"] == "Ana"


def test_buscar_pedido_inexistente_devuelve_none():
    servicio = ServicioPedidos(RepositorioVacioStub(), NotificadorStub())

    assert servicio.buscar_pedido(99) is None


def test_crear_pedido_guarda_y_notifica():
    repositorio = RepositorioFake()
    notificador = NotificadorMock()
    servicio = ServicioPedidos(repositorio, notificador)

    pedido = servicio.crear_pedido(
        "Ana",
        [
            {"nombre": "Teclado", "precio": 50000},
            {"nombre": "Mouse", "precio": 20000},
        ],
    )

    assert pedido["id"] == 1
    assert pedido["total"] == 70000
    assert pedido["estado"] == "creado"
    assert notificador.mensajes == [
        {
            "destinatario": "Ana",
            "mensaje": "Pedido 1 creado",
        }
    ]


def test_crear_pedido_sin_productos_lanza_error_y_no_notifica():
    repositorio = RepositorioFake()
    notificador = NotificadorMock()
    servicio = ServicioPedidos(repositorio, notificador)

    with pytest.raises(ValueError):
        servicio.crear_pedido("Ana", [])

    assert repositorio.pedidos == []
    assert notificador.mensajes == []


def test_cancelar_pedido_cambia_estado_y_notifica():
    repositorio = RepositorioFake()
    notificador = NotificadorMock()
    servicio = ServicioPedidos(repositorio, notificador)
    pedido = repositorio.guardar({
        "cliente": "Luis",
        "productos": [{"nombre": "Monitor", "precio": 120000}],
        "total": 120000,
        "estado": "creado",
    })

    cancelado = servicio.cancelar_pedido(pedido["id"])

    assert cancelado["estado"] == "cancelado"
    assert repositorio.obtener_por_id(pedido["id"])["estado"] == "cancelado"
    assert notificador.mensajes == [
        {
            "destinatario": "Luis",
            "mensaje": "Pedido 1 cancelado",
        }
    ]

24.15 Ejecutar las pruebas

Desde la raíz del proyecto, ejecuta:

python -m pytest

La salida esperada será similar a:

collected 5 items

test_pedidos.py .....                                           [100%]

5 passed in 0.04s

24.16 Cuándo usar cada doble

  • Usa un stub cuando solo necesitas una respuesta preparada.
  • Usa un fake cuando necesitas un comportamiento simple y repetible.
  • Usa un mock cuando necesitas verificar que una dependencia fue llamada correctamente.

No elijas el doble por el nombre. Elige según la necesidad concreta de la prueba.

24.17 Errores frecuentes

  • Probar el doble en lugar del código real: las aserciones deben concentrarse en el comportamiento del sistema bajo prueba.
  • Hacer mocks de todo: demasiados mocks vuelven frágiles las pruebas.
  • Crear fakes enormes: un fake debe ser simple y fácil de revisar.
  • No verificar efectos importantes: si enviar una notificación es parte del comportamiento, debe aparecer en la prueba.
  • Mezclar dependencias reales y dobles sin control: una prueba unitaria debe tener límites claros.

24.18 Comandos usados en este tema

mkdir pytest-dobles-demo
cd pytest-dobles-demo
python -m pip install pytest
python -m pytest
python -m pytest -v
python -m pytest test_pedidos.py::test_crear_pedido_guarda_y_notifica -v

24.19 Qué debes recordar de este tema

  • Los dobles reemplazan dependencias durante una prueba.
  • Un stub devuelve datos preparados.
  • Un fake implementa una versión simple de una dependencia.
  • Un mock registra llamadas para poder verificarlas.
  • La inyección de dependencias facilita usar dobles.
  • Los dobles deben mantener la prueba clara, no volverla más complicada.

24.20 Conclusión

En este tema construimos stubs, fakes y mocks manuales para probar un servicio sin usar una base de datos ni un sistema real de notificaciones.

En el próximo tema veremos unittest.mock, una herramienta incluida en Python que permite crear mocks con menos código manual.