Protocol Over Inheritance: Structural Test Seams in Python
Duck typing with a type checker? Python's Protocol does both. Since Python 3.8, typing.Protocol gives you structural subtyping: any object with the right shape satisfies the type, and the checker verifies it — no shared base class, no abc.ABC, no import of your class into the test double.
The problem with inheritance seams
To swap a real dependency for a fake in tests, the classic move is a shared abstract base class. But that couples three things that shouldn't be coupled:
- The real implementation must inherit the base.
- The test fake must import and inherit the same base.
- Every new implementation — including ones in code you don't own — must opt in.
That coupling spreads. Third-party classes can't retroactively inherit your ABC, so you end up writing adapter wrappers whose only job is to say "yes, I'm one of those."
The structural fix
Define a Protocol with just the method you need. Any object that implements it satisfies the type — the class never mentions the protocol:
from typing import Protocol
class Renderer(Protocol):
def render(self, template: str) -> str: ...
class HtmlRenderer: # no base class
def render(self, template: str) -> str:
return f"<html>{template}</html>"
class FakeRenderer: # test double: just implement the method
def render(self, template: str) -> str:
return "stub"
def publish(r: Renderer, tpl: str) -> str:
return r.render(tpl)
publish(HtmlRenderer(), "x") # type-checks
publish(FakeRenderer(), "x") # type-checks too
The typing spec is explicit: a concrete type is assignable to a protocol if and only if it implements all of the protocol's members — explicit inheritance is allowed but "not necessary … for the sake of type-checking." Your fake stays a plain class in the test file, and mypy/pyright still catch a renamed method or a wrong return type.
Rules of thumb
| Situation | Reach for |
|---|---|
| Test seam for one or two methods | Protocol — smallest interface wins |
| You control every implementation and want shared behavior | ABC with concrete helper methods |
| Third-party classes must satisfy the interface | Protocol (they can't inherit your ABC) |
You need isinstance() at runtime | @runtime_checkable Protocol — but see the caveat below |
| Attribute, not method | Protocols support data members: name: str in the body |
Power-user notes
@runtime_checkablechecks presence, not signatures. The docs warn thatisinstance()against a runtime-checkable protocol "will check only the presence of the required methods or attributes, not their type signatures or types." A method with the wrong signature still passes. Keep signature verification where it belongs — in the static checker.issubclass()only works on non-data protocols. Per the typing spec, protocols with attribute members supportisinstance()but notissubclass().- Runtime protocol checks are slow. The stdlib docs note an
isinstance()check against a runtime-checkable protocol "can be surprisingly slow"; preferhasattr()in hot paths. - Members come from the class body only. Attributes assigned via
selfinside methods don't count as protocol members — annotate them in the body. - Keep protocols narrow. A one-method protocol defined next to its consumer (not its implementations) is the Python spelling of the interface-segregation principle — and it's exactly the seam AI coding agents fake most cleanly, because the double needs zero imports from your production module.
Resources
typing.Protocol— Python standard library docs@runtime_checkable— presence-only checking caveat- Protocols — Python typing specification
- PEP 544 — Protocols: Structural subtyping (static duck typing)
- Protocols and structural subtyping — mypy docs
Watch the 60-second version: reel #127 in the Yeda AI Tips series. Building AI-assisted engineering workflows? Yeda AI designs, audits, and ships production LLM systems.