25. Uso básico de unittest.mock

25.1 Objetivo del tema

En el tema anterior construimos dobles de prueba manuales. Python incluye una herramienta para crear mocks sin escribir clases auxiliares: unittest.mock.

En este tema usaremos Mock para reemplazar dependencias, preparar valores de retorno, simular errores y verificar llamadas.

Idea clave: Mock sirve para controlar y observar una dependencia durante una prueba.

25.2 Crear una carpeta de práctica

Crea un proyecto nuevo:

mkdir pytest-mock-basico-demo
cd pytest-mock-basico-demo

unittest.mock viene incluido con Python. Para ejecutar las pruebas instalaremos pytest si no está disponible:

python -m pip install pytest

25.3 Crear el módulo a probar

Crea un archivo llamado reservas.py:

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

    def crear_reserva(self, cliente, habitacion, noches):
        if noches <= 0:
            raise ValueError("La cantidad de noches debe ser positiva")

        if not self.repositorio.habitacion_disponible(habitacion):
            raise ValueError("La habitación no está disponible")

        reserva = {
            "cliente": cliente,
            "habitacion": habitacion,
            "noches": noches,
            "estado": "confirmada",
        }

        reserva_guardada = self.repositorio.guardar(reserva)
        self.notificador.enviar(
            cliente,
            f"Reserva {reserva_guardada['id']} confirmada",
        )
        return reserva_guardada

    def cancelar_reserva(self, reserva_id):
        reserva = self.repositorio.obtener_por_id(reserva_id)
        if reserva is None:
            raise ValueError("Reserva inexistente")

        reserva["estado"] = "cancelada"
        self.repositorio.guardar(reserva)
        self.notificador.enviar(
            reserva["cliente"],
            f"Reserva {reserva_id} cancelada",
        )
        return reserva

El servicio depende de un repositorio y de un notificador. En las pruebas los reemplazaremos por mocks.

25.4 Crear un Mock básico

Importamos Mock desde unittest.mock:

from unittest.mock import Mock

Un mock puede tener métodos creados dinámicamente. Por ejemplo:

repositorio = Mock()
repositorio.habitacion_disponible.return_value = True

Cuando el código llame a repositorio.habitacion_disponible(...), devolverá True.

25.5 Configurar return_value

Probemos una reserva exitosa:

from unittest.mock import Mock

from reservas import ServicioReservas


def test_crear_reserva_confirmada():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.return_value = {
        "id": 1,
        "cliente": "Ana",
        "habitacion": "101",
        "noches": 3,
        "estado": "confirmada",
    }

    servicio = ServicioReservas(repositorio, notificador)

    resultado = servicio.crear_reserva("Ana", "101", 3)

    assert resultado["id"] == 1
    assert resultado["estado"] == "confirmada"

return_value define qué devuelve una llamada al mock.

25.6 Verificar llamadas con assert_called_once_with

Además del resultado, podemos verificar cómo se usaron las dependencias:

def test_crear_reserva_llama_a_dependencias():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.return_value = {
        "id": 1,
        "cliente": "Ana",
        "habitacion": "101",
        "noches": 3,
        "estado": "confirmada",
    }
    servicio = ServicioReservas(repositorio, notificador)

    servicio.crear_reserva("Ana", "101", 3)

    repositorio.habitacion_disponible.assert_called_once_with("101")
    notificador.enviar.assert_called_once_with(
        "Ana",
        "Reserva 1 confirmada",
    )

Esto confirma que el servicio consultó disponibilidad y envió la notificación esperada.

25.7 Verificar argumentos enviados al repositorio

Podemos revisar el diccionario que se pasó a guardar:

def test_crear_reserva_guarda_datos_correctos():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.return_value = {"id": 1}
    servicio = ServicioReservas(repositorio, notificador)

    servicio.crear_reserva("Ana", "101", 3)

    repositorio.guardar.assert_called_once_with({
        "cliente": "Ana",
        "habitacion": "101",
        "noches": 3,
        "estado": "confirmada",
    })

Esta prueba se concentra en el contrato entre el servicio y el repositorio.

25.8 Probar que algo no fue llamado

Si la habitación no está disponible, no se debe guardar ni notificar:

import pytest


def test_crear_reserva_sin_disponibilidad_no_guarda_ni_notifica():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = False
    servicio = ServicioReservas(repositorio, notificador)

    with pytest.raises(ValueError):
        servicio.crear_reserva("Ana", "101", 3)

    repositorio.guardar.assert_not_called()
    notificador.enviar.assert_not_called()

assert_not_called ayuda a verificar que una acción no ocurrió.

25.9 Simular errores con side_effect

side_effect permite lanzar excepciones desde un mock:

def test_crear_reserva_si_guardar_falla_no_notifica():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.side_effect = RuntimeError("Error de base de datos")
    servicio = ServicioReservas(repositorio, notificador)

    with pytest.raises(RuntimeError):
        servicio.crear_reserva("Ana", "101", 3)

    notificador.enviar.assert_not_called()

Esta prueba confirma que no se envía una notificación si el guardado falla.

25.10 side_effect con varios valores

side_effect también puede recibir una lista de respuestas:

def test_mock_con_varias_respuestas():
    generador = Mock()
    generador.codigo.side_effect = ["A1", "B2", "C3"]

    assert generador.codigo() == "A1"
    assert generador.codigo() == "B2"
    assert generador.codigo() == "C3"

Cada llamada consume el siguiente valor de la lista.

25.11 Probar cancelación con Mock

Para cancelar una reserva, preparamos lo que devuelve obtener_por_id:

def test_cancelar_reserva_actualiza_y_notifica():
    repositorio = Mock()
    notificador = Mock()
    repositorio.obtener_por_id.return_value = {
        "id": 5,
        "cliente": "Luis",
        "habitacion": "202",
        "noches": 2,
        "estado": "confirmada",
    }
    servicio = ServicioReservas(repositorio, notificador)

    resultado = servicio.cancelar_reserva(5)

    assert resultado["estado"] == "cancelada"
    repositorio.guardar.assert_called_once_with(resultado)
    notificador.enviar.assert_called_once_with(
        "Luis",
        "Reserva 5 cancelada",
    )

25.12 Probar reserva inexistente

Si el repositorio no encuentra la reserva, no debe guardar ni notificar:

def test_cancelar_reserva_inexistente_lanza_error():
    repositorio = Mock()
    notificador = Mock()
    repositorio.obtener_por_id.return_value = None
    servicio = ServicioReservas(repositorio, notificador)

    with pytest.raises(ValueError):
        servicio.cancelar_reserva(99)

    repositorio.guardar.assert_not_called()
    notificador.enviar.assert_not_called()

25.13 Archivo completo de pruebas

El archivo test_reservas.py puede quedar así:

from unittest.mock import Mock

import pytest

from reservas import ServicioReservas


def test_crear_reserva_confirmada():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.return_value = {
        "id": 1,
        "cliente": "Ana",
        "habitacion": "101",
        "noches": 3,
        "estado": "confirmada",
    }
    servicio = ServicioReservas(repositorio, notificador)

    resultado = servicio.crear_reserva("Ana", "101", 3)

    assert resultado["id"] == 1
    assert resultado["estado"] == "confirmada"


def test_crear_reserva_llama_a_dependencias():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.return_value = {
        "id": 1,
        "cliente": "Ana",
        "habitacion": "101",
        "noches": 3,
        "estado": "confirmada",
    }
    servicio = ServicioReservas(repositorio, notificador)

    servicio.crear_reserva("Ana", "101", 3)

    repositorio.habitacion_disponible.assert_called_once_with("101")
    notificador.enviar.assert_called_once_with(
        "Ana",
        "Reserva 1 confirmada",
    )


def test_crear_reserva_guarda_datos_correctos():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.return_value = {"id": 1}
    servicio = ServicioReservas(repositorio, notificador)

    servicio.crear_reserva("Ana", "101", 3)

    repositorio.guardar.assert_called_once_with({
        "cliente": "Ana",
        "habitacion": "101",
        "noches": 3,
        "estado": "confirmada",
    })


def test_crear_reserva_sin_disponibilidad_no_guarda_ni_notifica():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = False
    servicio = ServicioReservas(repositorio, notificador)

    with pytest.raises(ValueError):
        servicio.crear_reserva("Ana", "101", 3)

    repositorio.guardar.assert_not_called()
    notificador.enviar.assert_not_called()


def test_crear_reserva_si_guardar_falla_no_notifica():
    repositorio = Mock()
    notificador = Mock()
    repositorio.habitacion_disponible.return_value = True
    repositorio.guardar.side_effect = RuntimeError("Error de base de datos")
    servicio = ServicioReservas(repositorio, notificador)

    with pytest.raises(RuntimeError):
        servicio.crear_reserva("Ana", "101", 3)

    notificador.enviar.assert_not_called()


def test_mock_con_varias_respuestas():
    generador = Mock()
    generador.codigo.side_effect = ["A1", "B2", "C3"]

    assert generador.codigo() == "A1"
    assert generador.codigo() == "B2"
    assert generador.codigo() == "C3"


def test_cancelar_reserva_actualiza_y_notifica():
    repositorio = Mock()
    notificador = Mock()
    repositorio.obtener_por_id.return_value = {
        "id": 5,
        "cliente": "Luis",
        "habitacion": "202",
        "noches": 2,
        "estado": "confirmada",
    }
    servicio = ServicioReservas(repositorio, notificador)

    resultado = servicio.cancelar_reserva(5)

    assert resultado["estado"] == "cancelada"
    repositorio.guardar.assert_called_once_with(resultado)
    notificador.enviar.assert_called_once_with(
        "Luis",
        "Reserva 5 cancelada",
    )


def test_cancelar_reserva_inexistente_lanza_error():
    repositorio = Mock()
    notificador = Mock()
    repositorio.obtener_por_id.return_value = None
    servicio = ServicioReservas(repositorio, notificador)

    with pytest.raises(ValueError):
        servicio.cancelar_reserva(99)

    repositorio.guardar.assert_not_called()
    notificador.enviar.assert_not_called()

25.14 Ejecutar las pruebas

Desde la raíz del proyecto, ejecuta:

python -m pytest

La salida esperada será similar a:

collected 8 items

test_reservas.py ........                                       [100%]

8 passed in 0.05s

25.15 Métodos útiles de Mock

  • return_value: define el valor devuelto por una llamada.
  • side_effect: define una excepción, una función o una secuencia de respuestas.
  • assert_called_once_with: verifica una única llamada con argumentos concretos.
  • assert_not_called: verifica que el mock no haya sido llamado.
  • call_count: indica cuántas veces fue llamado.
  • call_args: permite inspeccionar los argumentos de la llamada más reciente.

25.16 Cuándo evitar un Mock

No todo necesita un mock. Si puedes probar una función pura directamente, hazlo. Si un fake simple en memoria expresa mejor el comportamiento, puede ser más claro que configurar muchos métodos en un mock.

Usa mocks para observar interacciones importantes, no para reemplazar cada objeto del programa.

25.17 Errores frecuentes

  • Verificar demasiados detalles internos: la prueba se vuelve frágil ante cambios de implementación.
  • No configurar return_value: un Mock devuelve otro Mock por defecto.
  • Mockear el objeto equivocado: en temas posteriores veremos cómo elegir correctamente qué reemplazar.
  • Confundir resultado con interacción: a veces basta con verificar el valor devuelto.
  • Ignorar caminos de error: side_effect ayuda a probar fallos de dependencias.

25.18 Comandos usados en este tema

mkdir pytest-mock-basico-demo
cd pytest-mock-basico-demo
python -m pip install pytest
python -m pytest
python -m pytest -v
python -m pytest test_reservas.py::test_crear_reserva_llama_a_dependencias -v

25.19 Qué debes recordar de este tema

  • unittest.mock viene incluido con Python.
  • Mock permite crear dobles de prueba rápidamente.
  • return_value configura respuestas exitosas.
  • side_effect permite simular errores o varias respuestas.
  • Los métodos assert_called... verifican interacciones.
  • Un buen mock debe aclarar la prueba, no hacerla más difícil de leer.

25.20 Conclusión

En este tema usamos unittest.mock.Mock para reemplazar dependencias, preparar respuestas, simular errores y verificar llamadas.

En el próximo tema profundizaremos en cómo mockear funciones, métodos y dependencias externas, prestando atención a dónde aplicar el reemplazo.