StackPractices
intermediate Por Mathias Paulenko

Mockear APIs Externas con responses

Cómo mockear llamadas HTTP en tests de Python usando la librería responses, incluyendo códigos de estado, headers, bodies JSON y simulación de errores.

Temas: testing

Visión General

La librería responses intercepta las peticiones HTTP hechas por la librería requests en tests de Python. En lugar de contactar servicios externos reales, responses devuelve respuestas mock predefinidas — tu suite deja de ser flaky porque cada llamada recibe la misma respuesta prefabricada, en milisegundos, sin red de por medio.

Cuándo Usarlo

  • Testear código que llama APIs REST de terceros (payment gateways, servicios de email, proveedores de SMS)
  • Simular respuestas de error de API (500s, timeouts, rate limits) sin un servicio real
  • Verificar que tu código envía el body, headers y query parameters correctos
  • Correr tests en CI sin acceso a red ni API keys

Cuándo NO Usarlo

  • Testear tus propios endpoints — usa un test client (e.g., FastAPI TestClient, Django TestCase)
  • Tests de integración que necesitan una base de datos real o message queue — usa Testcontainers
  • Load testing — los mocks no reflejan latencia ni throughput del mundo real
  • Testear webhook receivers o flujos que necesitan un servidor stub independiente — usa WireMock, MSW o herramientas de API mocking en general

Solución

Setup

pip install responses pytest

Mock response básico

import responses
import pytest
import requests

@responses.activate
def test_get_user():
    responses.add(
        method=responses.GET,
        url="https://api.example.com/users/1",
        json={"id": 1, "name": "Alice", "email": "alice@example.com"},
        status=200,
    )

    resp = requests.get("https://api.example.com/users/1")
    assert resp.status_code == 200
    assert resp.json()["name"] == "Alice"

Mock con headers y query params

import responses
import requests
from responses import matchers

@responses.activate
def test_search_with_params():
    responses.add(
        method=responses.GET,
        url="https://api.example.com/search",
        json={"results": [{"title": "Python Guide"}]},
        status=200,
        headers={"X-RateLimit-Remaining": "99"},
        match=[matchers.query_param_matcher({"q": "python"})],
    )

    resp = requests.get(
        "https://api.example.com/search",
        params={"q": "python", "page": 1},
        headers={"Authorization": "Bearer test-token"},
    )

    assert resp.headers["X-RateLimit-Remaining"] == "99"
    assert resp.json()["results"][0]["title"] == "Python Guide"

    # Verificar que la petición se hizo correctamente
    assert len(responses.calls) == 1
    assert "q=python" in responses.calls[0].request.url

Mock de respuestas de error

import responses
import pytest
import requests

@responses.activate
def test_handle_server_error():
    responses.add(
        method=responses.GET,
        url="https://api.example.com/users/1",
        status=500,
        json={"error": "Internal Server Error"},
    )

    with pytest.raises(requests.HTTPError):
        resp = requests.get("https://api.example.com/users/1")
        resp.raise_for_status()


@responses.activate
def test_handle_timeout():
    responses.add(
        method=responses.GET,
        url="https://api.example.com/slow-endpoint",
        body=requests.exceptions.Timeout("Connection timed out"),
    )

    with pytest.raises(requests.exceptions.Timeout):
        requests.get("https://api.example.com/slow-endpoint")

Múltiples respuestas para la misma URL

import responses
import requests

@responses.activate
def test_retry_logic():
    responses.add(
        method=responses.GET,
        url="https://api.example.com/data",
        status=503,
    )
    responses.add(
        method=responses.GET,
        url="https://api.example.com/data",
        status=200,
        json={"data": "success"},
    )

    # Primera llamada falla
    resp1 = requests.get("https://api.example.com/data")
    assert resp1.status_code == 503

    # Segunda llamada exitosa
    resp2 = requests.get("https://api.example.com/data")
    assert resp2.status_code == 200
    assert resp2.json()["data"] == "success"

Callback para respuestas dinámicas

import json
import responses
import requests

@responses.activate
def test_callback_response():
    def request_callback(request):
        payload = request.body
        if b"premium" in payload:
            return (200, {}, json.dumps({"tier": "premium", "quota": 10000}))
        return (200, {}, json.dumps({"tier": "free", "quota": 100}))

    responses.add_callback(
        method=responses.POST,
        url="https://api.example.com/subscribe",
        callback=request_callback,
        content_type="application/json",
    )

    resp = requests.post(
        "https://api.example.com/subscribe",
        json={"plan": "premium"},
    )
    assert resp.json()["quota"] == 10000

Verificar el body de la petición

import json
import responses
import requests

@responses.activate
def test_post_request_body():
    responses.add(
        method=responses.POST,
        url="https://api.example.com/orders",
        status=201,
        json={"id": 42, "status": "created"},
    )

    requests.post(
        "https://api.example.com/orders",
        json={"product_id": 10, "quantity": 3},
    )

    sent_body = json.loads(responses.calls[0].request.body)
    assert sent_body["product_id"] == 10
    assert sent_body["quantity"] == 3

Usar responses como fixture de pytest

import responses
import pytest
import requests

@pytest.fixture
def mock_api():
    with responses.RequestsMock() as rsps:
        yield rsps

def test_with_fixture(mock_api):
    mock_api.add(
        method=responses.GET,
        url="https://api.example.com/health",
        status=200,
        json={"status": "healthy"},
    )

    resp = requests.get("https://api.example.com/health")
    assert resp.json()["status"] == "healthy"

Suite ejecutable: todos los tests de esta página están en el repositorio complementario python-mock-external-apis-responses, incluido el fixture de conftest.py.

Explicación

responses funciona haciendo monkey-patch al transport adapter de requests. Cuando un test decorado con @responses.activate (o corriendo dentro de responses.RequestsMock()) llama a requests.get, la llamada nunca llega al socket — responses busca entre los mocks registrados y devuelve el primero que coincida.

El matching ocurre en dos etapas: primero la URL (string exacta o patrón re.compile) y el método HTTP, luego cualquier lista opcional match de matchers. Un mock registrado que coincide en URL pero falla un matcher se salta, no falla — responses sigue buscando en el registro, y si nada coincide la petición lanza ConnectionError, que es como se descubren los mocks olvidados.

Dos detalles operativos importan en la práctica. Primero, las respuestas registradas para la misma URL se consumen en orden — eso es lo que hace funcionar el ejemplo de retry de arriba. Segundo, el decorador resetea el registro en cada test; si gestionas RequestsMock tú mismo con un fixture, controlas cuándo se añaden mocks y cuándo aplica assert_all_requests_are_fired.

flowchart diagram: test llama a requests.get

Variantes

Usar httpx con respx

Si usas httpx en lugar de requests, usa la librería respx:

import respx
import httpx

@respx.mock
def test_httpx_mock():
    respx.get("https://api.example.com/users/1").respond(
        200, json={"id": 1, "name": "Alice"}
    )

    resp = httpx.get("https://api.example.com/users/1")
    assert resp.json()["name"] == "Alice"

Usar aioresponses para aiohttp

from aioresponses import aioresponses
import aiohttp
import pytest

@pytest.mark.asyncio
async def test_async_mock():
    with aioresponses() as m:
        m.get("https://api.example.com/data", payload={"key": "value"})

        async with aiohttp.ClientSession() as session:
            async with session.get("https://api.example.com/data") as resp:
                data = await resp.json()
                assert data["key"] == "value"

Buenas Prácticas

  • Siempre usa @responses.activate o el context manager — sin eso, las llamadas HTTP reales pasan
  • Verifica responses.calls para comprobar que tu código envió la petición correcta
  • Usa add_callback para lógica que una respuesta estática no puede expresar, como decidir según el body del request
  • Resetea responses.calls entre tests si estás verificando call counts
  • Mockea a nivel HTTP, no a nivel función — esto testea el path de integración real

Errores Comunes

  • Olvidar @responses.activate: sin eso, responses.add lanza un error o las peticiones reales pasan.
  • Confiar en el match exacto de URL: responses matchea URLs exactamente por defecto, así que un query string distinto falla tu mock en silencio. Usa matchers.query_param_matcher (o query_string_matcher) en la lista match — match_querystring se deprecó en responses 0.17.0 — o una URL regex cuando el path varía.
  • Mockear demasiados endpoints: si cada test mockea 10 endpoints, el setup del test se vuelve el test. Extrae mocks compartidos en fixtures.
  • No testear paths de error: mockea 500s, timeouts y rate limits — estos son los paths que fallan en producción.
  • Usar mocks para tests de integración: los mocks prueban tu parsing y manejo de errores — no dicen nada sobre si la API real sigue funcionando. Para eso necesitas contract tests contra el servicio real.

Preguntas frecuentes

¿Cómo hago match de URLs con regex?
import re
import responses

responses.add(
    method=responses.GET,
    url=re.compile(r"https://api\.example\.com/users/\d+"),
    json={"id": 1},
)
¿Cómo hago match por query parameters o headers?

Usa la lista match con los helpers de responses.matchers:

from responses import matchers

responses.add(
    method=responses.GET,
    url="https://api.example.com/search",
    json={"results": []},
    match=[
        matchers.query_param_matcher({"q": "python"}),
        matchers.header_matcher({"Authorization": "Bearer test-token"}),
    ],
)

Evita match_querystring=True — se deprecó en responses 0.17.0.

¿Cómo simulo un error de conexión?
import responses
import requests

responses.add(
    method=responses.GET,
    url="https://api.example.com/down",
    body=requests.exceptions.ConnectionError("Connection refused"),
)
¿Puedo mockear streaming responses?

Sí, pasa un generador a body:

import responses

def stream_generator():
    yield b"chunk1\n"
    yield b"chunk2\n"

responses.add(
    method=responses.GET,
    url="https://api.example.com/stream",
    body=stream_generator(),
    content_type="text/event-stream",
)
¿Cómo verifico cuántas veces se llamó un endpoint?
assert len(responses.calls) == 3
# O filtra por URL
api_calls = [c for c in responses.calls if "api.example.com" in c.request.url]
assert len(api_calls) == 3
¿Debería usar responses o unittest.mock.patch?

Usa responses cuando tu código usa la librería requests. Usa unittest.mock.patch cuando necesitas falsear una función o método en lugar de una llamada HTTP. responses ejercita el path HTTP real — headers, retries, raise_for_status — así que es el más realista de los dos.