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.

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

When Your API Integration Explodes in Production

Everything worked fine in testing. Then production hits—and suddenly your API integration turns into a disaster you didn’t see coming.

Read more

The Hidden Trap of Being a ‘Disguised Employee’

“You’re a contractor… but please come to the office every day.” That’s usually how it starts—then suddenly, you’re working like a full-time employee without realizing it.

Read more

Your Tests Are Coupled to Your Implementation and That Is Why They Keep Breaking

Tests that break every time you refactor are not telling you that refactoring is risky — they are telling you that the tests were written against implementation details rather than behavior. The coupling is the bug.

Read more

How to Laugh at Yourself After a Huge Mistake

We’ve all been there: the code breaks, the email goes to the wrong person, or the deployment wipes out production. Learning to laugh at these moments can save your sanity and even make you a better professional.

Read more