23. Mockear llamadas HTTP con requests y respuestas controladas

23.1 Objetivo del tema

Las llamadas HTTP son dependencias externas: pueden fallar, responder lento, cambiar datos o no estar disponibles. En pruebas unitarias normalmente queremos reemplazarlas por respuestas controladas.

En este tema veremos cómo mockear llamadas hechas con requests, cómo simular respuestas JSON, errores HTTP y timeouts, y cuándo conviene inyectar un cliente HTTP.

Objetivo práctico: probar código que consume APIs HTTP sin hacer llamadas reales a internet.

23.2 Función de ejemplo

Supongamos que tenemos este código en clientes_api.py:

import requests


def obtener_cliente(cliente_id):
    respuesta = requests.get(
        f"https://api.example.com/clientes/{cliente_id}",
        timeout=5,
    )
    respuesta.raise_for_status()
    return respuesta.json()

Queremos probar la función sin consultar la API real.

23.3 Mockear requests.get

Como el módulo usa requests.get, podemos parchear clientes_api.requests.get:

from unittest.mock import Mock, patch

from clientes_api import obtener_cliente


def test_obtener_cliente_exitoso():
    respuesta = Mock()
    respuesta.json.return_value = {"id": 1, "nombre": "Ana"}
    respuesta.raise_for_status.return_value = None

    with patch("clientes_api.requests.get", return_value=respuesta) as get_mock:
        cliente = obtener_cliente(1)

    assert cliente == {"id": 1, "nombre": "Ana"}
    get_mock.assert_called_once_with(
        "https://api.example.com/clientes/1",
        timeout=5,
    )
    respuesta.raise_for_status.assert_called_once_with()
    respuesta.json.assert_called_once_with()

La respuesta simulada implementa solo los métodos que el código usa.

23.4 Simular una respuesta con clase stub

Si prefieres evitar mocks anidados, puedes crear una respuesta stub:

class RespuestaHttpStub:
    def __init__(self, datos):
        self.datos = datos

    def raise_for_status(self):
        return None

    def json(self):
        return self.datos

Prueba:

def test_obtener_cliente_con_respuesta_stub():
    respuesta = RespuestaHttpStub({"id": 1, "nombre": "Ana"})

    with patch("clientes_api.requests.get", return_value=respuesta):
        cliente = obtener_cliente(1)

    assert cliente == {"id": 1, "nombre": "Ana"}

Esta opción puede ser más legible cuando la respuesta se reutiliza en muchas pruebas.

23.5 Simular error HTTP

requests lanza errores como requests.HTTPError cuando raise_for_status detecta un código de error.

import requests


def test_obtener_cliente_error_http():
    respuesta = Mock()
    respuesta.raise_for_status.side_effect = requests.HTTPError("404")

    with patch("clientes_api.requests.get", return_value=respuesta):
        with pytest.raises(requests.HTTPError):
            obtener_cliente(99)

La prueba simula un error sin depender de que la API real devuelva 404.

23.6 Traducir errores HTTP

Muchas aplicaciones no quieren exponer errores de requests al resto del sistema. Podemos traducirlos:

import requests


class ClienteNoDisponibleError(Exception):
    pass


def obtener_cliente(cliente_id):
    try:
        respuesta = requests.get(
            f"https://api.example.com/clientes/{cliente_id}",
            timeout=5,
        )
        respuesta.raise_for_status()
    except requests.RequestException as error:
        raise ClienteNoDisponibleError(
            "No se pudo obtener el cliente"
        ) from error

    return respuesta.json()

Ahora podemos probar que se lanza el error propio.

23.7 Probar error traducido

Prueba:

import pytest
import requests

from clientes_api import ClienteNoDisponibleError


def test_obtener_cliente_traduce_error_http():
    respuesta = Mock()
    respuesta.raise_for_status.side_effect = requests.HTTPError("500")

    with patch("clientes_api.requests.get", return_value=respuesta):
        with pytest.raises(ClienteNoDisponibleError):
            obtener_cliente(1)

La prueba verifica el contrato de nuestra aplicación, no el detalle interno de requests.

23.8 Simular timeout

Un timeout ocurre durante la llamada a requests.get:

def test_obtener_cliente_timeout():
    with patch(
        "clientes_api.requests.get",
        side_effect=requests.Timeout("Tiempo agotado"),
    ):
        with pytest.raises(ClienteNoDisponibleError):
            obtener_cliente(1)

Aquí el mock de get lanza la excepción directamente.

23.9 Verificar parámetros HTTP

Cuando el código envía parámetros, conviene verificarlos:

def buscar_clientes(nombre):
    respuesta = requests.get(
        "https://api.example.com/clientes",
        params={"nombre": nombre},
        timeout=5,
    )
    respuesta.raise_for_status()
    return respuesta.json()

Prueba:

def test_buscar_clientes_verifica_params():
    respuesta = Mock()
    respuesta.raise_for_status.return_value = None
    respuesta.json.return_value = [{"id": 1, "nombre": "Ana"}]

    with patch("clientes_api.requests.get", return_value=respuesta) as get_mock:
        clientes = buscar_clientes("Ana")

    assert clientes == [{"id": 1, "nombre": "Ana"}]
    get_mock.assert_called_once_with(
        "https://api.example.com/clientes",
        params={"nombre": "Ana"},
        timeout=5,
    )

23.10 Mockear POST

Para enviar datos:

def crear_cliente(datos):
    respuesta = requests.post(
        "https://api.example.com/clientes",
        json=datos,
        timeout=5,
    )
    respuesta.raise_for_status()
    return respuesta.json()

Prueba:

def test_crear_cliente():
    respuesta = Mock()
    respuesta.raise_for_status.return_value = None
    respuesta.json.return_value = {"id": 10, "nombre": "Ana"}
    datos = {"nombre": "Ana"}

    with patch("clientes_api.requests.post", return_value=respuesta) as post_mock:
        cliente = crear_cliente(datos)

    assert cliente == {"id": 10, "nombre": "Ana"}
    post_mock.assert_called_once_with(
        "https://api.example.com/clientes",
        json=datos,
        timeout=5,
    )

23.11 Inyectar cliente HTTP

Otra opción es recibir el cliente HTTP como dependencia:

def obtener_cliente(cliente_id, cliente_http):
    respuesta = cliente_http.get(
        f"https://api.example.com/clientes/{cliente_id}",
        timeout=5,
    )
    respuesta.raise_for_status()
    return respuesta.json()

La prueba no necesita patch:

def test_obtener_cliente_con_cliente_inyectado():
    cliente_http = Mock()
    respuesta = Mock()
    respuesta.raise_for_status.return_value = None
    respuesta.json.return_value = {"id": 1, "nombre": "Ana"}
    cliente_http.get.return_value = respuesta

    cliente = obtener_cliente(1, cliente_http)

    assert cliente == {"id": 1, "nombre": "Ana"}

23.12 Fake de cliente HTTP

También podemos crear un fake sencillo:

class ClienteHttpFake:
    def __init__(self, respuestas):
        self.respuestas = respuestas
        self.llamadas = []

    def get(self, url, **kwargs):
        self.llamadas.append({"metodo": "GET", "url": url, "kwargs": kwargs})
        return self.respuestas[url]

Este fake puede ser útil si varias pruebas consultan distintas URLs controladas.

23.13 Respuesta fake

Combinamos el fake con una respuesta simple:

class RespuestaFake:
    def __init__(self, datos):
        self.datos = datos

    def raise_for_status(self):
        return None

    def json(self):
        return self.datos

Uso:

def test_obtener_cliente_con_fake():
    url = "https://api.example.com/clientes/1"
    cliente_http = ClienteHttpFake({
        url: RespuestaFake({"id": 1, "nombre": "Ana"})
    })

    cliente = obtener_cliente(1, cliente_http)

    assert cliente == {"id": 1, "nombre": "Ana"}
    assert cliente_http.llamadas[0]["url"] == url

23.14 Cuidado con mocks demasiado acoplados

Si una prueba verifica cada detalle interno de requests, puede volverse frágil. Verifica lo que sea parte del contrato: URL, método, parámetros importantes, timeout y resultado esperado.

No siempre hace falta verificar cada llamada a raise_for_status si el comportamiento final ya cubre el caso. Pero en clientes HTTP suele ser razonable comprobarlo porque omitirlo puede ocultar errores HTTP.

23.15 No hacer llamadas reales en pruebas unitarias

Una prueba unitaria no debería depender de internet. Las llamadas reales pueden fallar por red, límites de API, credenciales o cambios de datos.

Para probar integración con una API real, usa pruebas de integración separadas y controladas. No mezcles ese objetivo con pruebas unitarias.

23.16 Ejercicio práctico

Prueba esta función:

import requests


def obtener_cotizacion(moneda):
    respuesta = requests.get(
        "https://api.example.com/cotizaciones",
        params={"moneda": moneda},
        timeout=3,
    )
    respuesta.raise_for_status()
    datos = respuesta.json()
    return datos["valor"]

Escribe una prueba de respuesta exitosa y otra de timeout.

23.17 Solución posible del ejercicio

Una solución:

from unittest.mock import Mock, patch

import pytest
import requests

from cotizaciones import obtener_cotizacion


def test_obtener_cotizacion_exitosa():
    respuesta = Mock()
    respuesta.raise_for_status.return_value = None
    respuesta.json.return_value = {"valor": 1000}

    with patch("cotizaciones.requests.get", return_value=respuesta) as get_mock:
        valor = obtener_cotizacion("USD")

    assert valor == 1000
    get_mock.assert_called_once_with(
        "https://api.example.com/cotizaciones",
        params={"moneda": "USD"},
        timeout=3,
    )


def test_obtener_cotizacion_timeout():
    with patch(
        "cotizaciones.requests.get",
        side_effect=requests.Timeout("Tiempo agotado"),
    ):
        with pytest.raises(requests.Timeout):
            obtener_cotizacion("USD")

La prueba controla completamente la respuesta HTTP y el error de red.

23.18 Conclusión

Mockear llamadas HTTP permite probar clientes de APIs sin depender de internet. Podemos reemplazar requests.get o requests.post, simular respuestas JSON, errores HTTP y timeouts, y verificar parámetros importantes.

En el próximo tema veremos cómo probar envío de correos, notificaciones y colas de mensajes.