How does typing.Protocol let a hand-written fake stand in for a real collaborator?
answer
- Name the shape, not the lineage
- Structural, not nominal, conformance
- Declare only the methods you call
- A checker catches the fake drifting
- isinstance sees names, never signatures
basics
~20 sDeclare a Protocol naming only the methods your code calls, then annotate the injected parameter with it. Conformance is structural, so a fake matches by having those methods - no base class - and a static checker verifies both sides.
solid answer
~50 s`typing.Protocol` describes a shape rather than a lineage. You write a tiny protocol naming just the methods your code calls on the collaborator, annotate the injected parameter with it, and any object with those methods satisfies it - the real implementation and your hand-written fake alike, neither inheriting anything. That turns the seam into something a static type checker can police: if the real collaborator gains a parameter or the protocol grows a method, the fake stops conforming and the check fails, which is the drift that patch-built doubles never catch. Keep the protocol narrow - declare what you call, not the real class's whole surface - and put it next to the consumer. `typing.runtime_checkable` additionally allows `isinstance`, but that check only confirms the attributes exist; it never inspects signatures, so it is no substitute for the static check.
code
python · 23 linesfrom typing import Protocol
class Store(Protocol):
def save(self, invoice_id: str, pdf: bytes) -> None: ...
class FakeStore:
def __init__(self) -> None:
self.saved: dict[str, bytes] = {}
def save(self, invoice_id: str, pdf: bytes) -> None:
self.saved[invoice_id] = pdf
def render(invoice_id: str, store: Store) -> int:
pdf = b"%PDF-1.7 " + invoice_id.encode()
store.save(invoice_id, pdf)
return len(pdf)
fake = FakeStore()
print(render("INV-17", fake), sorted(fake.saved))go deeper
Know that Python does not need an interface keyword: a fake works if it has the right methods. Recognise typing.Protocol when you see it as the way to write that expectation down.
Explain structural versus nominal conformance, why the protocol belongs next to the consumer and lists only the calls made, and what runtime_checkable does and does not verify.
Show the payoff you care about: the type checker running over the tests turns fake drift into a check failure instead of a false green. Be candid that with no checker in CI a protocol is only documentation.
Decide whether the codebase adopts protocol-typed seams at all - it is a policy about where interfaces live, how many implementations justify one, and whether type checking gates the build. Weigh the annotation upkeep against the class of silent-drift bug it removes.
Python's duck typing already lets a fake stand in for a real collaborator: pass an object with the right methods and it works. `typing.Protocol` adds the missing half - a *name* for that expected shape that tools can check - without giving up the duck typing. ### Nominal versus structural An `abc.ABC` base class is **nominal**: an object counts as a store because it inherits from `StoreBase` (or was registered with it). That forces your test double to import and subclass a production class, which drags in its constructor, its `__init__` I/O and its whole method surface, and it points the dependency arrow the wrong way - the consumer now depends on a hierarchy someone else owns. `typing.Protocol` is **structural**: an object counts as a store because it has the methods the protocol declares. Nothing is inherited, nothing is registered, and the protocol can live in the module that *consumes* it - the consumer states what it needs, and both the real implementation and the fake are checked against that statement. ```python from typing import Protocol class Store(Protocol): def save(self, invoice_id: str, pdf: bytes) -> None: ... def render(invoice_id: str, store: Store) -> int: ... ``` ### What this buys the test **The fake is checked, not merely hoped at.** Pass a `FakeStore` into a `Store`-annotated parameter and a static type checker verifies method names, parameter counts, parameter kinds and return types. Add a parameter to the real `save` and update the protocol, and every fake that has not kept up fails the check before a test ever runs. Compare that with a double built by patching, which is a shape-shifter: it accepts any call, so the test happily asserts on a call that no longer exists in production. The important caveat is that this only works if the type checker actually runs over the test code - exclude the tests from checking and the guarantee evaporates. **The protocol is a design statement.** A protocol declaring one method tells the reader the consumer needs exactly one method, and it makes the fake trivial. A protocol that mirrors a fifteen-method production class is a sign that the consumer is reaching too far into its collaborator. Writing the protocol from the *call sites* rather than from the real class is the discipline that keeps it honest. **It survives multiple implementations.** The real store, the in-memory fake, a recording wrapper and a null implementation are all just objects with `save`. None of them know about each other. ### Runtime checking and its limits By default, `isinstance` against a protocol raises `TypeError`. Decorating the protocol with `typing.runtime_checkable` enables `isinstance`, but the check is deliberately shallow: it confirms the named attributes and methods *exist*, and never inspects signatures. A class whose `save` takes no arguments passes it. `issubclass` is rejected outright for protocols that declare non-method members. So `runtime_checkable` is fine for a coarse guard at a plugin boundary and is not a way to validate that a fake is faithful - that job belongs to the static checker and to running the same tests against fake and real. ### Practical shape Keep protocols small and local. A protocol per consumer beats one shared `IStore` interface, because each consumer names only what it uses and the fakes stay tiny. Generic protocols are available when the seam is parameterised - since 3.12 you can write `class Store[T](Protocol): ...` with the PEP 695 type-parameter syntax, and the older `typing.Generic`-style spelling still works. Protocols also compose with the injection itself. The parameter is typed with the protocol; the default (if any) resolves to the concrete implementation inside the body; the test passes the fake. Nothing at run time is different from plain duck typing - protocols are erased, they add no dispatch cost, and a `...` body in a protocol method is never executed. ### When it is not worth it In a codebase with no type checker in CI, a protocol is documentation with syntax - still useful, but it buys none of the drift protection, and a docstring may do the job. For a seam with exactly one implementation and one fake used in one test, a plain untyped parameter is fine. The value grows with the number of implementations, the number of tests that build fakes, and how often the real collaborator's interface changes.
- Why declare the protocol in the consuming module rather than beside the real implementation?Because the consumer is the one that knows what it needs. Declared next to the caller, the protocol lists only the methods that caller uses, so it stays small, the fake stays small, and the real implementation does not have to import anything to satisfy it. Declared beside the implementation it drifts into a mirror of that class, and every consumer inherits a surface it does not use.
- How does a Protocol-typed seam catch drift that a patch-built double misses?A patched double accepts any call - wrong name, wrong arity, wrong types - so a test keeps passing after the real method changes. With the seam typed by a protocol, updating the protocol to match the real collaborator immediately fails the type check on every fake that has not kept up, and the failure is at check time rather than in production. It only holds if the checker runs over the test files.
- Does adding Protocol annotations cost anything at run time?Effectively nothing. Annotations are not enforced at run time, dispatch is unchanged, and a protocol method body of `...` is never executed. The only run-time cost appears if you use `typing.runtime_checkable` and call `isinstance`, which does a real attribute-presence check on every call.
saying these in an interview costs you the question
- Says a fake must inherit from the Protocol to conform
- Thinks Protocol annotations are enforced at run time
- Believes isinstance against a Protocol validates signatures
- Mirrors the whole production class in the protocol
- Excludes test files from type checking, then trusts the fake
- Confuses typing.Protocol with a network or dunder protocol