Stop Mocking Things You Do Not Own

by Arif Ikhsanudin, Backend Developer

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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Deleting Code Is One of the Most Underrated Engineering Skills

Every line of code that exists must be maintained, understood, and tested. Deleting code that no longer serves a purpose is not cleanup — it is removing a permanent tax from your team's velocity.

Read more

Stop Returning Everything When the Client Only Needs a Few Fields

Over-fetching is a performance problem and a data leakage problem. Sparse fieldsets and response projection are the tools that solve it.

Read more

How US Startups Use Async Backend Contractors to Move Fast Without the Burn Rate

Your burn rate doesn't care that you're still onboarding your new backend hire. It just keeps burning.

Read more

Building a Rails API That Clients Actually Enjoy Working With

A Rails API that works correctly for the team that built it is not the same as one that's pleasant to integrate against. Here are the design decisions that determine whether clients come back with questions or with praise.

Read more