A practical comparison of two approaches that look similar but behave very differently

The Problem Every Developer Has Faced

You need to fetch data from an external API. Your code works perfectly in development, but now you need tests. The problem is obvious: you can’t hit the real API in your test suite. It’s slow, expensive, rate-limited, and unreliable.

So what do you do?

If you’re like most Python developers, you reach for @patch("requests.get"). It’s built into the standard library, it’s fast, and Stack Overflow is full of examples. Your tests run in milliseconds instead of seconds, your CI/CD pipeline stays green, and you feel good about “proper” testing practices.

But there’s a subtle problem with this approach that will bite you later — usually during refactors, migrations, or scaling the test suite. Let me show you what I mean.

The Starting Point: Real API Calls

Here’s our simple app that fetches data:

# app.py
import requests
from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/posts/<int:post_id>")
def get_post_data(post_id):
    """
    Fetch data from external API.
    This API is expensive, rate limited, and slow.
    """

    url = f"https://jsonplaceholder.typicode.com/posts/{post_id}"

    try:
        response = requests.get(url)
        response.raise_for_status()
        return jsonify(response.json())
    except requests.RequestException as e:
        return jsonify({"error": str(e)}), 500

if __name__ == "__main__":
    app.run(debug=True)

The tests that actually hit the API look like this:

# test_app.py (slow, flaky version)
import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

def test_get_post_data(client):
    response = client.get("/posts/1")

    assert response.status_code == 200

    data = response.get_json()
    assert "title" in data
    assert "body" in data

These tests work, but they’re painful:

  • Slow: Network calls add seconds to your test suite
  • Flaky: Network issues cause random failures
  • Expensive: Rate limits and API quotas
  • Brittle: External API changes break your tests

So what’s the solution?

The Common Fix: Patching with @patch

Most developers reach for Python’s built-in unittest.mock and @patch. It looks like this:

# test_app.py (common patching approach)
from unittest.mock import Mock, patch
import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

@patch("requests.get")
def test_get_post_data(mock_get, client):
    mock_response = Mock()
    mock_response.json.return_value = {
        "id": 1,
        "title": "Test Post Title",
        "body": "Test post body content",
        "userId": 1,
    }
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    response = client.get("/posts/1")

    assert response.status_code == 200

    data = response.get_json()
    assert "title" in data
    assert "body" in data

This approach looks great, right?

  • ✅ Tests run in milliseconds
  • ✅ No network dependencies
  • ✅ Easy to control responses and test error scenarios
  • ✅ Built into Python’s standard library

At small scale, this works fine. At team scale, it quietly accumulates cost.

Why Patching Breaks (And Why You Don’t Notice Until It’s Too Late)

This approach has subtle but critical flaws that turn into technical debt over time.

1. Brittle Coupling to Implementation Details

Your tests are tightly coupled to the specific HTTP library (requests.get). This causes problems if you:

  • Switch to httpx for async support
  • Use urllib3 for connection pooling
  • Migrate to aiohttp for better performance

Every test breaks, even though the business logic hasn’t changed. At scale, this slows down migrations and makes refactoring risky.

2. Hidden Dependencies

Looking at your route handler, can you tell what external services it depends on?

@app.route("/posts/<int:post_id>")
def get_post_data(post_id):

The dependency is invisible. New team members have to read the implementation to understand what this code talks to.

3. Implementation Leakage

Your tests know too much:

@patch("requests.get")
def test_get_post_data_success(self, mock_get):
    mock_get.assert_called_once_with(
        "https://jsonplaceholder.typicode.com/posts/1"
    )

This creates tight coupling between tests and internal mechanics. Refactors that should be safe become noisy and expensive.

4. False Confidence

This is the most dangerous problem: tests pass while real bugs slip through.

Patched tests often won’t catch:

  • URL construction errors
  • Edge-case parsing failures
  • Error-handling gaps
  • Authentication or header mistakes
  • Performance regressions

You get green checkmarks, but you’re not exercising the real code paths.

A Better Approach: Introduce a Test Seam via Dependency Injection

Instead of patching a global function, make the dependency explicit by injecting it:

# app.py
import requests
from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/posts/<int:post_id>")
def get_post_data(post_id, get_data_abstraction=requests.get):
    try:
        url = f"https://jsonplaceholder.typicode.com/posts/{post_id}"

        response = get_data_abstraction(url)
        response.raise_for_status()
        return jsonify(response.json())
    except requests.RequestException as e:
        return jsonify({"error": str(e)}), 500

if __name__ == "__main__":
    app.run(debug=True)

This introduces a test seam — a deliberate boundary where infrastructure can be substituted without altering business logic.

Instead of patching global functions, we inject a controlled implementation directly into the function:

# test_app.py
from unittest.mock import Mock
import pytest
from app import app, get_post_data

@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

def test_get_post_data(client):
    mock_response = Mock()
    mock_response.json.return_value = {
        "id": 1,
        "title": "Test Post Title",
        "body": "Test post body content",
        "userId": 1,
    }
    mock_response.raise_for_status.return_value = None

    def mock_http_client(url):
        assert url == "https://jsonplaceholder.typicode.com/posts/1"
        return mock_response

    with app.test_request_context():
        result = get_post_data(1, get_data_abstraction=mock_http_client)
        data = result.get_json()

        assert result.status_code == 200
        assert "title" in data
        assert "body" in data

Why This Pattern Scales in Real Codebases

Same Code Path, Different Boundary

The key insight is that we are testing the exact same business logic. Only the network boundary changes.

This is a boundary abstraction, not a mock.

Explicit Dependencies

The function signature now documents what this code depends on:

def get_post_data(post_id, get_data_abstraction=requests.get):

That clarity matters as systems and teams grow.

Refactoring and Migration Safety

Switching to httpx becomes trivial:

import httpx

@app.route("/posts/<int:post_id>")
def get_post_data(post_id, get_data_abstraction=httpx.get):

Tests don’t change. This is the difference between test suites that enable change and ones that resist it.

Real Confidence

Because tests exercise real code paths, they catch real failures:

  • URL construction
  • Parsing behavior
  • Error handling
  • Control flow

This is the foundation of reliable test infrastructure.

The Bottom Line

Both approaches produce fast tests. Only one produces systems that can evolve safely.

Patching optimizes for convenience today. Dependency injection optimizes for change tomorrow.

This pattern is what I look for when rehabilitating brittle codebases or building test infrastructure that teams can trust over time.


Want to see the complete code for all three approaches? Check out the demo repository with branches showing the progression from problem to solution.