Stop Mocking Things You Do Not Own
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Test That Breaks on a Library Upgrade
You upgrade the AWS SDK from v2 to v3. The client interface changed — a few method names, a few parameter shapes. Your code adapts in about two hours. But the test suite takes a day to fix, because dozens of tests are mocking AmazonS3Client directly, and all those mocks now reference the old interface.
This is the cost of mocking things you do not own. The external library's interface became part of your test suite's dependency surface, and when it changed, your tests broke even though your application behavior was unchanged.
The Rule and the Reason
"Do not mock what you do not own" was articulated by Steve Freeman and Nat Pryce in Growing Object-Oriented Software, Guided by Tests. The reasoning is this: a mock is a specification of how you expect a collaborator to behave. If the collaborator is code you do not own, your specification of its behavior might be wrong — and divergence between the mock and the real thing will cause your tests to pass while your production code fails.
External libraries do not guarantee the behavior you specify in your mocks. They guarantee their documented contract. When your mock says s3.putObject(...) returns a PutObjectResult with a specific ETag, you are assuming something about library internals that the library does not promise. If the library changes that behavior while preserving its public contract, your mock stays stale, your tests keep passing, and your code breaks in production.
The Pattern That Fixes It
Introduce an adapter — a thin wrapper you own — between your code and the external dependency. Mock the adapter, not the library.
# Don't do this: mocking Boto3 directly
def test_stores_document_in_s3():
mock_s3 = Mock()
mock_s3.put_object.return_value = {"ETag": '"abc123"'}
service = DocumentService(s3_client=mock_s3)
service.store("doc_id_1", b"content")
mock_s3.put_object.assert_called_once_with(
Bucket="documents",
Key="doc_id_1",
Body=b"content"
)
# Do this instead: own the interface you depend on
class DocumentStore(Protocol):
def save(self, key: str, content: bytes) -> None: ...
def load(self, key: str) -> bytes: ...
class S3DocumentStore:
def __init__(self, s3_client, bucket: str):
self._client = s3_client
self._bucket = bucket
def save(self, key: str, content: bytes) -> None:
self._client.put_object(Bucket=self._bucket, Key=key, Body=content)
def load(self, key: str) -> bytes:
response = self._client.get_object(Bucket=self._bucket, Key=key)
return response["Body"].read()
# Now mock the interface you own
def test_stores_document():
mock_store = Mock(spec=DocumentStore)
service = DocumentService(document_store=mock_store)
service.store("doc_id_1", b"content")
mock_store.save.assert_called_once_with("doc_id_1", b"content")
DocumentService now depends on DocumentStore — an interface you own and control. When Boto3 changes, only S3DocumentStore needs to update. The test for DocumentService is unaffected because it was never coupled to Boto3's interface.
S3DocumentStore gets its own integration test — one test that actually calls Boto3 (against a LocalStack container or moto mock) and verifies the adapter works correctly. This test is in the integration suite, runs less frequently, and is the only test that couples your suite to the Boto3 interface.
The Same Pattern for HTTP APIs
The same logic applies to HTTP clients. If your code uses the requests library or axios to call an external API, mocking requests.get directly in unit tests is mocking something you do not own.
Introduce a typed client class:
// Own the interface
interface PaymentGatewayClient {
charge(amount: number, token: string): Promise<ChargeResult>;
refund(transactionId: string): Promise<RefundResult>;
}
// Implement it against the real API
class StripeGatewayClient implements PaymentGatewayClient {
async charge(amount: number, token: string): Promise<ChargeResult> {
const response = await axios.post('https://api.stripe.com/v1/charges', {
amount, source: token, currency: 'usd'
}, { headers: { Authorization: `Bearer ${this.apiKey}` }});
return { transactionId: response.data.id, status: 'approved' };
}
// ...
}
// In unit tests: mock PaymentGatewayClient (your interface)
// In integration tests: use WireMock or Stripe's test mode to test StripeGatewayClient
Your business logic tests never see Stripe's API surface. If Stripe changes their response schema, StripeGatewayClient adapts, the integration test verifies the adapter, and everything else is unaffected.
The adapter layer is a small investment. The payoff is a test suite that is isolated from library churn — and production code that is genuinely decoupled from specific vendor implementations.