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.mockin 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.
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.
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
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_valuewith a realistic payload, ideally built from a recorded response. - Using
spec=instead of autospec.Mock(spec=ObjectStore)restricts attribute names but not arguments. Usecreate_autospecfor 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.
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.
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.
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.
Related
- Autospec & Strict Mocking — what autospec enforces when it can see the methods.
- Catching Signature Drift with spec_set — the attribute-assignment half of strictness.
- Dependency Injection for Testability — passing the adapter in rather than constructing the SDK inside.
- Recording and Replaying HTTP with VCR.py — realistic return payloads for the adapter's integration tests.
← Back to Autospec & Strict Mocking