Isolation & Contracts

Autospeccing Clients That Use __getattr__

create_autospec is the strongest defence against mocks that drift from reality: it copies the real object's attributes and signatures, and rejects calls that the real object would reject. It depends on one assumption — that the real object's methods exist as attributes on its class. Many SDKs break that assumption. AWS's boto3, several generated API clients, and most ORMs' dynamic managers build their methods at runtime through __getattr__ or from a service description, and autospec, inspecting the class, finds nothing to copy.

The result is a mock that looks strict and accepts anything. client.put_objct(Bucket="b") — a misspelling — passes, as does a call with an argument the real API does not take. The fix is to give autospec something concrete to inspect, and the best candidates are a Protocol declaring the operations your code depends on, or a thin adapter that wraps the SDK behind methods you own.

The problem is easy to miss because nothing fails. A team adopts autospec across the suite, sees it catch signature drift in its own code, and reasonably assumes the SDK mocks are equally protected. They are not, and the first sign is usually a production error on a call the tests exercised a hundred times — against a mock that would have accepted anything. Checking which of a suite's autospecced targets are dynamic, and fixing those specifically, is a small audit with an unusually high return.

Prerequisites

  • Python 3.8+ for typing.Protocol; unittest.mock in the standard library.
  • The autospec fundamentals from autospec and strict mocking.
  • A dynamic client to double — boto3 is used as the example, but the pattern is general.

Solution

First, confirm the problem: the method you call is not a real attribute.

Python
import boto3

client = boto3.client("s3")
print("put_object" in dir(type(client)))      # False: generated at runtime

Then give autospec a concrete specification of what your code actually uses.

Python
from typing import Any, Protocol
from unittest.mock import create_autospec


class ObjectStore(Protocol):
    """The slice of the S3 client this service depends on."""

    def put_object(self, *, Bucket: str, Key: str, Body: bytes,
                   ContentType: str = ...) -> dict[str, Any]: ...

    def get_object(self, *, Bucket: str, Key: str) -> dict[str, Any]: ...


def test_upload_uses_the_right_key():
    store = create_autospec(ObjectStore, instance=True)

    upload_invoice(store, invoice_id="inv_1", pdf=b"%PDF")

    store.put_object.assert_called_once_with(
        Bucket="invoices", Key="inv_1.pdf", Body=b"%PDF", ContentType="application/pdf"
    )


def test_misspelt_method_now_fails():
    store = create_autospec(ObjectStore, instance=True)
    import pytest
    with pytest.raises(AttributeError):
        store.put_objct(Bucket="b", Key="k", Body=b"")   # caught by the spec
What autospec sees on a dynamic client versus a Protocol Two inspections. On the dynamic SDK client class, autospec finds only a __getattr__ hook and no concrete methods, so the resulting mock accepts any attribute and any arguments. On a Protocol listing put_object and get_object with signatures, autospec finds two concrete methods and enforces both names and arguments. Autospec can only enforce what it can see autospec(boto3 client class) class attributes: __getattr__ no put_object, no signatures put_objct(…) accepted wrong kwargs accepted looks strict, enforces nothing autospec(ObjectStore Protocol) put_object(*, Bucket, Key, Body, …) get_object(*, Bucket, Key) put_objct → AttributeError wrong kwargs → TypeError strict, and documents the dependency
The Protocol also narrows the mock to what the code actually uses — a few methods out of the SDK's hundreds — which makes it easier to read and harder to misuse.

Why this works

create_autospec walks the specification object with dir() and inspect.signature, creating a child mock for each attribute it finds and binding each callable child to the real signature. A class whose methods come from __getattr__ has no such attributes to find, so autospec produces a mock with no constraints at all — it cannot enforce a name it never saw.

A Protocol is an ordinary class whose methods are declared with real signatures, so autospec finds them exactly as it would on a concrete class. The mock then rejects unknown attribute names with AttributeError and wrong arguments with TypeError, which restores every guarantee autospec normally gives. The same Protocol serves as a type annotation in the production code, so mypy or pyright checks the real call sites against the same declaration the tests use. That double use is what makes the Protocol worth writing: one declaration constrains both the tests and the code, so the two cannot disagree about what the dependency looks like without a type error or a test failure pointing it out.

Edge cases and failure modes

  • Protocol drifting from the SDK. If the SDK renames an argument and the Protocol does not follow, tests pass against the old signature. Verify the real client against the Protocol in an integration test.
  • Keyword-only parameters. Many SDKs take only keyword arguments. Declare them after * in the Protocol so positional calls fail as they would against the real API.
  • Return shapes. The Protocol's return annotation is not enforced by autospec. Configure return_value with a realistic payload, ideally built from a recorded response.
  • Using spec= instead of autospec. Mock(spec=ObjectStore) restricts attribute names but not arguments. Use create_autospec for both.
  • Too broad a Protocol. Copying the SDK's whole surface into a Protocol recreates the problem of a huge double. Declare only what the code calls.

An adapter as the better boundary

The Protocol approach keeps the SDK in the production code and narrows the test double. An adapter goes one step further and narrows the production dependency too: a small class that exposes the operations the service needs, in its own vocabulary, and hides the SDK entirely.

Python
class InvoiceStorage:
    """Everything the billing service needs from object storage."""

    def __init__(self, client, bucket: str) -> None:
        self._client, self._bucket = client, bucket

    def save_pdf(self, invoice_id: str, pdf: bytes) -> None:
        self._client.put_object(Bucket=self._bucket, Key=f"{invoice_id}.pdf",
                                Body=pdf, ContentType="application/pdf")

    def load_pdf(self, invoice_id: str) -> bytes:
        response = self._client.get_object(Bucket=self._bucket, Key=f"{invoice_id}.pdf")
        return response["Body"].read()

Business-logic tests now mock InvoiceStorage — two methods with plain signatures, fully autospecced — and never mention S3 at all. The adapter itself gets a handful of integration tests against a real or emulated bucket, which is where the SDK's behaviour genuinely needs checking. An SDK upgrade that changes argument names touches one file and its tests, instead of every test that mocked the client. The adapter's method names also describe the domain rather than the vendor — save_pdf rather than put_object — which makes the business-logic tests read as statements about invoices instead of statements about a storage API, and makes a later change of storage provider a matter of writing a second adapter rather than touching the service.

The adapter is also where a fake belongs, if one is wanted: an InMemoryInvoiceStorage implementing the same two methods over a dictionary is a few lines, needs no mocking library, and can share a contract suite with the real adapter — the approach described in writing an in-memory fake repository.

Where the mock boundary sits with and without an adapter Without an adapter, the business logic calls the SDK client directly and every test must mock the SDK's dynamic surface. With an adapter, the business logic calls two plain methods on an InvoiceStorage class, tests mock or fake that small class, and only the adapter's own integration tests touch the SDK. Mock the boundary you own billing service InvoiceStorage save_pdf · load_pdf boto3 client dynamic surface business-logic tests autospec or fake InvoiceStorage — two plain methods never mention S3 adapter tests real or emulated bucket the only SDK contact
An SDK upgrade now touches the adapter and its few tests, not every test in the service.

Keeping the Protocol honest

A Protocol written by hand is a claim about the SDK, and like any claim it can go stale. The SDK adds a required argument, renames a keyword, changes a return shape — and every test autospecced against the old Protocol keeps passing. The defence is a single integration test that checks the real client against the declaration.

Two checks cover most of the risk. The first is structural: for each method declared in the Protocol, confirm the real client has it and that its accepted parameters include every name the Protocol declares. Dynamic clients often expose their operations through a service model — boto3's client.meta.service_model lists every operation and its input shape — which makes this a straightforward comparison rather than a guess. Clients generated from OpenAPI or protobuf definitions usually expose an equivalent description, and where none exists, inspect.signature on a bound method of a real instance often works even when the class itself shows nothing. The second is behavioural: call each method once against a real or emulated service with the arguments the production code uses, and check the response has the fields the code reads.

Running that test in the integration stage, and whenever the SDK version changes, turns the Protocol from documentation into a verified contract. It fails exactly when the SDK changes in a way the rest of the suite would otherwise not notice, and it names the method and parameter that drifted, which makes the fix a one-line edit to the Protocol followed by whatever changes the production code needs.

Verifying the Protocol against the real SDK A Protocol declares the methods and parameters the code depends on. An integration test compares it against the real client in two ways: structurally, by checking the service model lists each operation with the declared parameters, and behaviourally, by calling each method against a real or emulated service and checking the response fields. A mismatch fails the test and names the drifted method. The Protocol is a claim; one test verifies it ObjectStore declared methods structural check service model lists the params behavioural check real call, expected fields back mismatch names the drifted method
Without this test, the Protocol ages silently. With it, an SDK upgrade either passes cleanly or produces a precise list of what changed.

Frequently Asked Questions

Why does create_autospec accept any method on my SDK client? Because the client's methods do not exist as attributes on the class; they are produced at runtime by __getattr__ or built from a service description. autospec inspects the class, finds only the dynamic hook, and has nothing to restrict against, so it falls back to accepting everything.

What is the cleanest way to get a strict mock of such a client? Define a Protocol listing the methods your code actually calls, with their signatures, and autospec that. The Protocol documents your real dependency, gives the type checker something to check, and gives autospec concrete methods to enforce.

Should I wrap third-party SDK clients in my own adapter? Usually, yes. A thin adapter exposing the handful of operations your code needs gives a stable, strictly-typed boundary to mock, isolates SDK changes to one module, and makes the test doubles far simpler than doubling the full SDK surface.

← Back to Autospec & Strict Mocking