Testing External APIs: Why Patching Breaks and Dependency Injection Doesn't
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
httpxfor async support - Use
urllib3for connection pooling - Migrate to
aiohttpfor 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.