Mocking Libraries and Small Tests

This guide explains how mocking libraries work with pytest-test-categories and why mocked tests remain hermetic even when they appear to make external calls.

Key Insight: Mocks Bypass the Network Blocker by Design

When you use mocking libraries like responses, respx, pytest-mock, or unittest.mock, your tests remain fully hermetic even though the code under test appears to make network calls. This is intentional and correct behavior.

Why? Mocking libraries intercept HTTP calls at the library level (requests, httpx, etc.) before they reach Python’s socket layer. Since our network blocker operates at the socket level, mocked calls never trigger it.

Without mocking:
  Your code → requests.get() → socket.connect() → BLOCKED by pytest-test-categories

With mocking:
  Your code → requests.get() → responses mock → returns fake data (never reaches socket)

Mocked Tests Are Still Hermetic

A test using mocks is hermetic because:

  1. No actual network traffic occurs - The mock intercepts the call before any real connection

  2. Tests are deterministic - Mock responses are controlled and predictable

  3. Tests are fast - No network latency

  4. Tests are isolated - No dependency on external services

  5. Tests are parallelizable - No shared network resources

This is exactly the behavior you want for small tests.

Common Mocking Libraries

responses (for requests library)

The responses library mocks the requests library at the session level:

import pytest
import requests
import responses

@pytest.mark.small
@responses.activate
def test_api_client_fetches_user():
    """This test is hermetic - responses intercepts before socket layer."""
    responses.add(
        responses.GET,
        "https://api.example.com/users/123",
        json={"id": "123", "name": "Alice", "email": "alice@example.com"},
        status=200,
    )

    # This call APPEARS to access the network but never does
    response = requests.get("https://api.example.com/users/123")

    assert response.status_code == 200
    assert response.json()["name"] == "Alice"

respx (for httpx library)

The respx library mocks httpx at the transport level:

import pytest
import httpx
import respx

@pytest.mark.small
@respx.mock
def test_httpx_client():
    """Hermetic test using respx for httpx."""
    respx.get("https://api.example.com/health").respond(
        json={"status": "healthy"},
        status_code=200,
    )

    response = httpx.get("https://api.example.com/health")

    assert response.json()["status"] == "healthy"

pytest-mock (for any library)

The pytest-mock library provides the mocker fixture for general-purpose mocking:

import pytest

@pytest.mark.small
def test_user_service_creates_user(mocker):
    """Hermetic test using pytest-mock."""
    # Mock the HTTP client at the point of use
    mock_post = mocker.patch("myapp.services.httpx.post")
    mock_post.return_value.json.return_value = {"id": "456", "name": "Bob"}
    mock_post.return_value.status_code = 201

    from myapp.services import UserService

    service = UserService()
    user = service.create_user(name="Bob", email="bob@example.com")

    assert user["id"] == "456"
    mock_post.assert_called_once()

unittest.mock (standard library)

Python’s built-in mocking works the same way:

import pytest
from unittest.mock import Mock, patch

@pytest.mark.small
@patch("requests.get")
def test_weather_service(mock_get):
    """Hermetic test using unittest.mock."""
    mock_response = Mock()
    mock_response.json.return_value = {"temp": 72, "conditions": "sunny"}
    mock_response.status_code = 200
    mock_get.return_value = mock_response

    from myapp.weather import get_current_weather

    weather = get_current_weather("Seattle")

    assert weather["temp"] == 72

VCR.py (record/replay)

VCR.py records real HTTP interactions and replays them in subsequent test runs:

import pytest
import vcr

@pytest.mark.small
@vcr.use_cassette("tests/cassettes/user_api.yaml")
def test_user_api_response_format():
    """Hermetic test using recorded cassette."""
    import requests

    # First run: records real response to cassette file
    # Subsequent runs: replays from cassette (no network access)
    response = requests.get("https://api.example.com/users/123")

    assert "id" in response.json()
    assert "name" in response.json()

Note: After recording, VCR.py tests are hermetic. During the initial recording, you may need to temporarily use @pytest.mark.large or disable enforcement.

Architecture: How the Layers Work

Understanding the architecture helps explain why this works:

┌─────────────────────────────────────────────────────────────────┐
│                    Your Test Code                               │
│   response = requests.get("https://api.example.com/users/1")    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                 Mocking Library Layer                           │
│   responses, respx, httpretty, pytest-mock, etc.                │
│                                                                 │
│   ✓ Intercepts HTTP calls HERE                                  │
│   ✓ Returns fake response                                       │
│   ✗ Never calls lower layers                                    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ (only reached if NOT mocked)
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                   HTTP Library Layer                            │
│   requests, httpx, urllib3, aiohttp, etc.                       │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Socket Layer                                 │
│   socket.socket.connect()                                       │
│                                                                 │
│   ✗ pytest-test-categories blocks HERE                          │
│   ✗ Only reached if mocking is NOT active                       │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Operating System                              │
│   TCP/IP network stack                                          │
└─────────────────────────────────────────────────────────────────┘

Best Practices

1. Choose the Right Mocking Library

Library

Best For

HTTP Library

responses

Simple request/response mocking

requests

respx

Async-first, modern API

httpx

aioresponses

Async HTTP mocking

aiohttp

httpretty

Library-agnostic mocking

Any

pytest-mock

General-purpose, DI patterns

Any

VCR.py

Record/replay patterns

Any

2. Mock at the Right Level

Preferred: Mock at the HTTP library level (responses, respx)

@responses.activate
def test_user_fetch():
    responses.add(responses.GET, "https://api.example.com/users/1", json={"id": 1})
    # Test code that uses requests internally

Also good: Mock at your service boundary

def test_user_service(mocker):
    mocker.patch.object(api_client, "get_user", return_value={"id": 1})
    # Test code that uses api_client

Avoid: Mocking too deep (socket level) - let the blocker do its job

3. Keep Mocks Minimal

Only mock what you need to test:

# Good: Minimal mock for the test case
@pytest.mark.small
@responses.activate
def test_handles_404_error():
    responses.add(responses.GET, "https://api.example.com/users/999", status=404)

    with pytest.raises(UserNotFoundError):
        fetch_user(999)

# Avoid: Over-mocking unrelated endpoints
@pytest.mark.small
@responses.activate
def test_handles_404_error():
    responses.add(responses.GET, "https://api.example.com/users/999", status=404)
    responses.add(responses.GET, "https://api.example.com/health", json={})  # Unnecessary
    responses.add(responses.POST, "https://api.example.com/users", json={})  # Unnecessary
    # ...

4. Use Dependency Injection for Testability

Design your code to accept dependencies, making it easy to inject mocks:

# Production code with DI
class UserService:
    def __init__(self, http_client=None):
        self.client = http_client or httpx.Client()

    def get_user(self, user_id: str) -> dict:
        response = self.client.get(f"https://api.example.com/users/{user_id}")
        return response.json()

# Test code - inject mock client
@pytest.mark.small
def test_user_service_returns_user(mocker):
    mock_client = mocker.Mock()
    mock_client.get.return_value.json.return_value = {"id": "123", "name": "Alice"}

    service = UserService(http_client=mock_client)
    user = service.get_user("123")

    assert user["name"] == "Alice"

Frequently Asked Questions

Q: If mocks bypass the blocker, how do I know my test is hermetic?

The network blocker is a safety net, not your only line of defense. If your test uses proper mocking:

  • No real network calls occur

  • The test is deterministic and fast

  • The test would pass even if the network were disconnected

The blocker catches cases where you forgot to mock, or where mocking failed.

Q: Should I use mocking or change to medium/large tests?

Use mocking when:

  • Testing business logic that happens to call external services

  • The network call is incidental to what you’re testing

  • You want fast, deterministic feedback

Change to medium/large when:

  • Testing the actual integration with an external service

  • Verifying real network behavior (timeouts, retries, TLS)

  • Testing with real databases or local services

Q: What if my mock setup fails and a real call gets through?

That’s exactly when the network blocker helps. If your mock doesn’t properly intercept a call, the blocker will catch it and fail the test with a clear error message, rather than making a real network call that could be slow, flaky, or have side effects.

Q: Do I need to use @responses.activate or similar decorators?

Yes, mocking libraries typically require explicit activation:

Library

Activation Method

responses

@responses.activate decorator or context manager

respx

@respx.mock decorator or context manager

aioresponses

aioresponses() context manager

httpretty

@httpretty.activate decorator

pytest-mock

Automatic via mocker fixture

VCR.py

@vcr.use_cassette() decorator

If you forget activation, the real call will go through and the network blocker will catch it.