Pattern #1 — Test The Real API
The most simple way to test this is to use the real API.
"""Pattern 1 - Full Integration Test"""
from src.file_uploader import upload_file
def test_upload_file():
file_name = "sample.txt"
response = upload_file(file_name)
assert response["success"] is True
assert response["name"] == file_name
assert response["key"] is not None
First, create a simple TXT file in the same folder and call it sample.txt
.
Let’s move on to Mocking, which in my opinion is an Anti-Pattern for testing 3rd party API integrations.
Let’s learn what NOT to do.
Pattern 2- Mocking/Patching The Requests Library
The most straightforward way is to Mock the requests library.
"""Pattern 2 - Mock the Request Library"""
from unittest.mock import patch, Mock, ANY
from src.file_uploader import upload_file
def test_upload_file():
file_name = "sample.txt"
stub_upload_response = {
"success": True,
"link": "https://file.io/TEST",
"key": "TEST",
"name": file_name,
}
with patch("src.file_uploader.requests.post") as mock_post:
mock_post_response = Mock()
mock_post_response.status_code = 200
mock_post_response.json.return_value = stub_upload_response
mock_post.return_value = mock_post_response
response = upload_file(file_name)
assert response["success"] is True
assert response["link"] == "https://file.io/TEST"
assert response["name"] == file_name
mock_post.assert_called_once_with("https://file.io", files={"file": ANY})
If I may ask you, what’s wrong with this or why is this undesirable?
Let’s say your boss tells you to add more functionality. Perhaps the download_file
, update_file
or delete_file
functionality.
Cons
-
Tightly Coupled to Implementation
-
Brittle and Hard to Maintain
-
Extra Effort to Patch Every Test: You need to remember to apply
@patch
or use a context manager in every test that may trigger API calls, increasing test setup requirements. -
Potential for Mixing Business Logic and I/O Concerns
-
Likely Need for Integration and E2E Tests: Since mocks don’t guarantee that the code will work with the actual API, you often need separate integration and end-to-end tests to validate real API behavior.
As you can see I’m not a big fan of this approach for anything other than very simple code.
So what’s the solution?
Pattern #3 — Build an Adaptor/Wrapper Around The 3rd Party API
An interesting idea I got from Harry Percival’s YouTube video — Stop Using Mocks (for a while) — was the concept of an adaptor.
Harry describes it as a wrapper around the 3rd party API or around the I/O.
The wrapper is built to support your interactions with the 3rd party API and is written in your own words (not based directly on the 3rd party Swagger).
src/file_uploader_adaptor.py
import requests
class FileIOAdapter:
API_URL = "https://file.io"
def upload_file(self, file_path):
with open(file_path, "rb") as file:
response = requests.post(self.API_URL, files={"file": file})
return response.json()
How could we test it?
We’ll use mock/patch here as well but instead of mocking the requests.post
library we’ll patch the upload_file
method of the FileIOAdaptor
class.
tests/unit/test_pattern3.py
"""Pattern 3 - Create an Adaptor Class and Mock It"""
from unittest import mock
from src.file_uploader_adaptor import FileIOAdapter
def test_upload_file():
with mock.patch(
"src.file_uploader_adaptor.FileIOAdapter.upload_file"
) as mock_upload_file:
mock_upload_file.return_value = {"success": True}
adapter = FileIOAdapter()
response = adapter.upload_file("sample.txt")
assert response == {"success": True}
mock_upload_file.assert_called_once_with("sample.txt")
Pattern #4 —Dependency Injection
Next, let’s look at another design pattern — dependency injection.
Instead of having our business logic (upload functionality) baked into the FIleUploader class, how about we pass it as a dependency.
from abc import ABC, abstractmethod
import requests
class FileUploader(ABC):
@abstractmethod
def upload_file(self, file_path: str) -> dict:
raise NotImplementedError("Method not implemented")
class FileIOUploader(FileUploader):
API_URL = "https://file.io"
def upload_file(self, file_path: str) -> dict:
with open(file_path, "rb") as file:
response = requests.post(self.API_URL, files={"file": file})
return response.json()
def process_file_upload(file_path: str, uploader: FileUploader):
response = uploader.upload_file(file_path)
return response
Pattern #5 — Use a Fake with Dependency Injection
The last pattern (Dependency Injection) is really good but still uses mocks.
What if you wanted to avoid mocks completely?
You can use a Fake instead.
Fake is a different kind of test double to mocks and patches — which is an in-memory representation of the object you’re trying to replace. That’s my informal definition.
tests/unit/test_pattern5.py
"""Pattern 5: Dependency Injection + Fake - Create a Fake Object and Inject It"""
from src.file_uploader_dep_injection import process_file_upload
class FakeFileIOUploader:
def __init__(self):
self.uploaded_files = {}
def upload_file(self, file_path: str) -> dict:
# Simulate uploading by "saving" the file's path in a dictionary
self.uploaded_files[file_path] = f"https://file.io/fake-{file_path}"
return {"success": True, "link": self.uploaded_files[file_path]}
def test_process_file_upload_with_fake():
fake_uploader = FakeFileIOUploader()
result = process_file_upload("test.txt", uploader=fake_uploader)
assert result["success"] is True
assert result["link"] == "https://file.io/fake-test.txt"
assert "test.txt" in fake_uploader.uploaded_files
Pattern #6 — Use a Sandbox API
Using a Sandbox API is a realistic approach for testing external API interactions.
However, while sandbox testing is safer than hitting production, it’s generally slower than using fakes or mocks and you have to remember to clean up to avoid having tons of messy data in your sandbox.