Mocking con unittest.mock: Mock, MagicMock y patch

Cuando pruebas código que depende de una base de datos, una API externa o el sistema de archivos, tienes un problema: esas dependencias son lentas, impredecibles o simplemente no están disponibles en tu entorno de CI. Mocking es la técnica de reemplazar esas dependencias con objetos falsos que controlas por completo. En Python, unittest.mock es la herramienta estándar para hacerlo.

Los tres actores principales

Mock es un objeto que acepta cualquier llamada o acceso a atributo sin lanzar AttributeError. Internamente registra todo lo que le ocurre: cuántas veces fue llamado, con qué argumentos, qué atributos se accedieron. Es un espía y un doble al mismo tiempo.

MagicMock hereda de Mock y añade implementaciones predeterminadas de los dunder methods (__len__, __enter__, __exit__, __iter__, etc.). Si tu código usa el mock dentro de un with, hace aritmética con él, o llama a len(), necesitas MagicMock. Con un Mock plano, esas operaciones lanzarían TypeError porque los dunders no se resuelven por el mecanismo normal de atributos en Python, sino directamente en la clase.

patch es donde ocurre la magia real. No basta con crear un mock: hay que colocarlo en el namespace correcto, el mismo donde el código bajo prueba lo va a buscar. patch('modulo.Clase') sustituye temporalmente ese nombre en ese módulo durante la prueba y lo restaura después, sin importar si el test pasa o falla.

La regla de oro es parchear donde se usa, no donde se define. Si servicio.py hace from requests import get, tienes que parchear servicio.get, no requests.get. Si parcheas el lugar equivocado, el código de producción sigue usando el objeto real y el mock nunca intercepta nada.

¿Cuándo mockear? Siempre que la dependencia sea externa a tu unidad de lógica: HTTP, base de datos, reloj del sistema, filesystem, colas de mensajes. ¿Cuándo no? Nunca mockees la lógica de negocio que estás probando: estarías probando el mock, no tu código.

# payment_service.py
import requests


def charge_card(card_token: str, amount_cents: int) -> dict:
    """Llama a una pasarela de pago externa."""
    response = requests.post(
        "https://api.payments.example/charge",
        json={"token": card_token, "amount": amount_cents},
        timeout=5,
    )
    response.raise_for_status()
    return response.json()


def process_payment(card_token: str, amount_cents: int) -> str:
    if amount_cents <= 0:
        raise ValueError("El importe debe ser positivo")

    result = charge_card(card_token, amount_cents)

    if result.get("status") == "approved":
        return f"Pago aprobado: {result['transaction_id']}"
    return "Pago rechazado"
# test_payment_service.py
from unittest.mock import MagicMock, call, patch

import pytest

import payment_service


class TestChargeCard:
    def test_successful_charge(self):
        # Parcheamos requests.post en el namespace donde se usa
        with patch("payment_service.requests.post") as mock_post:
            mock_response = MagicMock()
            mock_response.json.return_value = {
                "status": "approved",
                "transaction_id": "txn_abc123",
            }
            # raise_for_status no debe hacer nada en el camino feliz
            mock_response.raise_for_status.return_value = None
            mock_post.return_value = mock_response

            result = payment_service.charge_card("tok_visa", 1500)

        assert result == {"status": "approved", "transaction_id": "txn_abc123"}
        mock_post.assert_called_once_with(
            "https://api.payments.example/charge",
            json={"token": "tok_visa", "amount": 1500},
            timeout=5,
        )

    def test_http_error_propagates(self):
        with patch("payment_service.requests.post") as mock_post:
            mock_response = MagicMock()
            # Simulamos que la pasarela devuelve 4xx/5xx
            mock_response.raise_for_status.side_effect = Exception("HTTP 402")
            mock_post.return_value = mock_response

            with pytest.raises(Exception, match="HTTP 402"):
                payment_service.charge_card("tok_bad", 500)


class TestProcessPayment:
    # Decorador: más limpio cuando el mock se necesita en todo el método
    @patch("payment_service.charge_card")
    def test_approved_payment(self, mock_charge):
        mock_charge.return_value = {
            "status": "approved",
            "transaction_id": "txn_xyz",
        }

        result = payment_service.process_payment("tok_visa", 2000)

        assert result == "Pago aprobado: txn_xyz"
        assert mock_charge.call_count == 1

    @patch("payment_service.charge_card")
    def test_rejected_payment(self, mock_charge):
        mock_charge.return_value = {"status": "declined"}

        result = payment_service.process_payment("tok_visa", 2000)

        assert result == "Pago rechazado"

    def test_negative_amount_raises(self):
        # No necesitamos mock: la validación ocurre antes de la llamada externa
        with pytest.raises(ValueError, match="positivo"):
            payment_service.process_payment("tok_visa", -100)

    @patch("payment_service.charge_card")
    def test_charge_not_called_on_invalid_amount(self, mock_charge):
        with pytest.raises(ValueError):
            payment_service.process_payment("tok_visa", 0)

        # Verificamos que nunca llegamos a llamar a la pasarela
        mock_charge.assert_not_called()


class TestCallInspection:
    """Demuestra las herramientas de inspección de llamadas."""

    @patch("payment_service.charge_card")
    def test_call_args_inspection(self, mock_charge):
        mock_charge.return_value = {"status": "approved", "transaction_id": "t1"}

        payment_service.process_payment("tok_amex", 3000)
        payment_service.process_payment("tok_visa", 1000)

        assert mock_charge.call_count == 2

        # call_args contiene los argumentos de la ÚLTIMA llamada
        last_args, last_kwargs = mock_charge.call_args
        assert last_args == ("tok_visa", 1000)

        # call_args_list contiene todas las llamadas en orden
        expected_calls = [
            call("tok_amex", 3000),
            call("tok_visa", 1000),
        ]
        mock_charge.assert_has_calls(expected_calls)

Lo que hace cada decisión

MagicMock para la respuesta HTTP, no Mock: la respuesta de requests se usa como objeto de retorno de un post, y luego llamamos .json() sobre ella. MagicMock genera automáticamente submocks para cada atributo accedido, así que mock_response.json ya es un callable configurable sin que tengamos que declararlo explícitamente. Con Mock funcionaría igual en este caso, pero en cuanto el código haga len(response) o with response:, necesitarías MagicMock.

return_value vs side_effect: return_value fija lo que devuelve el mock cuando se le llama. side_effect es más potente: puede ser una excepción (se lanza automáticamente), un iterable (devuelve valores en secuencia en llamadas sucesivas) o una función (se ejecuta con los mismos argumentos). Para simular errores de red, side_effect es la forma natural.

Context manager vs decorador para patch: son equivalentes en comportamiento. El context manager da más control sobre el scope exacto del parche y es mejor cuando solo necesitas el mock en parte del test. El decorador es más limpio cuando el mock se usa durante todo el método; el objeto parcheado llega como argumento en orden inverso a los decoradores (el patch más cercano al método es el primer argumento).

test_negative_amount_raises sin mock: fíjate en que este test no parchea nada. La validación de importe negativo vive en tu lógica de negocio, no toca ninguna dependencia externa, y probarla sin mock es exactamente correcto. Introducir un mock ahí sería ruido innecesario.

call_args_list y assert_has_calls: cuando el mismo mock se llama varias veces, call_args solo muestra la última invocación. Para verificar la secuencia completa usas call_args_list directamente o assert_has_calls con objetos call(...). Esto es especialmente útil en código que procesa lotes.

Errores que debes conocer

Error: Parchear el módulo de origen en lugar del namespace de uso; el mock existe pero el código sigue usando el objeto real.

# ❌ Wrong
# requests está definido en el paquete `requests`, pero payment_service
# ya tiene su propia referencia local
with patch("requests.post") as mock_post:
    payment_service.charge_card("tok", 100)
    mock_post.assert_called_once()  # Falla: nunca fue llamado

# ✅ Right
with patch("payment_service.requests.post") as mock_post:
    payment_service.charge_card("tok", 100)
    mock_post.assert_called_once()

Recuerda: patch reemplaza el nombre en el módulo que lo importó, no en su lugar de definición.


Error: Configurar return_value en el mock incorrecto cuando hay niveles de indirección.

# ❌ Wrong
with patch("payment_service.requests.post") as mock_post:
    # Ponemos el return_value directamente en mock_post,
    # pero el código llama mock_post(...).json(), no mock_post.json()
    mock_post.json.return_value = {"status": "approved"}
    result = payment_service.charge_card("tok", 100)
    # result["status"] lanza KeyError porque .json() devuelve un MagicMock

# ✅ Right
with patch("payment_service.requests.post") as mock_post:
    mock_post.return_value.json.return_value = {"status": "approved"}
    mock_post.return_value.raise_for_status.return_value = None
    result = payment_service.charge_card("tok", 100)
    assert result["status"] == "approved"

mock_post.return_value es el objeto que se devuelve cuando llamas a mock_post(...), que es la respuesta HTTP; sus métodos viven ahí, no en el mock de post directamente.


Error: Usar assert_called_with cuando se necesita assert_called_once_with; el primero solo verifica la última llamada sin importar cuántas veces ocurrió.

# ❌ Wrong — pasa aunque charge_card se llamó 5 veces
mock_charge.assert_called_with("tok_visa", 1000)

# ✅ Right — falla si fue llamado más de una vez
mock_charge.assert_called_once_with("tok_visa", 1000)

assert_called_once_with combina la verificación de argumentos con la de cardinalidad; úsalo cuando la lógica exige exactamente una invocación.

158

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio