Why is a collaborator constructed inside a Python class harder to test than one passed in?
answer
- Where can a test get in?
- Two seams: parameter or import
- Signature as the dependency list
- Pass a small fake instead of patching
- None default keeps callers unchanged
basics
~20 sA collaborator built inside the class gives a test no way in, so the only override left is patching the name the module imported. Take the collaborator as a constructor parameter and the test simply passes a small fake object.
solid answer
~40 sA class that does `self.store = InvoiceStore()` in `__init__` hard-wires that dependency: nothing the test passes can change it, so the test has to reach into the module and replace the imported name with `unittest.mock.patch`. That works, but it couples the test to the import graph rather than to behaviour, and it breaks when the code moves. Taking the collaborator as a parameter creates a *seam* in the signature: `def __init__(self, store)`, or `store=None` resolved to the real one in the body so production callers stay unchanged. The test then passes a hand-written fake - a small class holding a dict - and asserts against its recorded state. Python needs no framework and no base class for this; duck typing means the fake only has to provide the methods actually called.
code
python · 27 linesclass InvoiceStore:
def save(self, invoice_id, pdf):
raise RuntimeError("would write to the real archive")
class Renderer:
def __init__(self, store=None):
self.store = InvoiceStore() if store is None else store
def render(self, invoice_id):
pdf = b"%PDF-1.7 " + invoice_id.encode()
self.store.save(invoice_id, pdf)
return len(pdf)
class FakeStore:
def __init__(self):
self.saved = {}
def save(self, invoice_id, pdf):
self.saved[invoice_id] = pdf
fake = FakeStore()
assert Renderer(fake).render("INV-17") > 0
assert fake.saved["INV-17"].startswith(b"%PDF")
print(sorted(fake.saved))go deeper
Be ready to show the two-line change: move the collaborator from being built inside __init__ to being a parameter, then pass a small fake class in the test. Say out loud why the test no longer needs to know which module imported what.
Explain the mechanics: a patch target is a run-time string, a parameter is checked immediately; a None default keeps production call sites unchanged; is None rather than or avoids the falsy-collaborator trap.
Show judgement about which dependencies deserve a seam - things that do I/O, cross a process boundary, or are slow or non-deterministic - and be able to describe threading them up to a single composition root without turning every constructor into a parameter list.
Own the boundary policy: which collaborators the codebase injects by convention, where fakes live and who maintains them, and how you would migrate a patch-heavy suite incrementally without a big-bang rewrite of the tests.
A **seam** is a place where you can change what a piece of code does without editing that code. Python offers essentially two seams for a *collaborator* - an object your code calls out to, such as a store, an HTTP client, or a queue publisher - and choosing between them is a design decision, not a test detail. ### The two seams The first seam is **the parameter**. If a class or function takes its collaborator as an argument, a test supplies whatever it likes: ```python class Renderer: def __init__(self, store): self.store = store ``` The second seam is **the import**. If the class builds the collaborator itself, the only remaining handle is the name in the module namespace, which `unittest.mock.patch` can temporarily replace: ```python class Renderer: def __init__(self): self.store = InvoiceStore() # nothing to pass, nothing to override ``` Both make the test pass. They differ in what the test ends up depending on. ### Why the parameter seam wins by default **It is the honest dependency list.** The signature says what the object needs. A reader who wants to know what a class touches reads its `__init__`, not the whole body plus every module-level import. **It survives refactoring.** A patch target is a *string* resolved at run time. Move the module, rename it, or change where the name is imported, and the patch either fails loudly or - worse - patches a name nobody calls, so the test passes while quietly exercising the real collaborator. A parameter is checked by the interpreter and by a static type checker; a string is checked by nothing until the test runs. **It keeps the test about behaviour.** A patch-based test encodes the *structure* of the code under test: which module imports what. Restructure the code without changing behaviour and the tests go red anyway - the classic sign of tests that are pinned to implementation. **The fake documents the contract.** A hand-written in-memory double is a few lines and is executable documentation of the small slice of the interface that is actually used: ```python class FakeStore: def __init__(self): self.saved = {} def save(self, invoice_id, pdf): self.saved[invoice_id] = pdf ``` Assertions then read against real state (`fake.saved["INV-17"]`) instead of against call records, which is usually clearer and does not go stale when an argument is added. ### Keeping production callers happy The common objection is that injection forces every caller to build the dependency. The idiomatic Python answer is a default resolved in the body: ```python def __init__(self, store=None): self.store = InvoiceStore() if store is None else store ``` Use `is None`, not `or`: a legitimate collaborator can be falsy (an empty in-memory fake that defines `__len__` or `__bool__` would silently be replaced by the real one). Note that the default expression must not be `store=InvoiceStore()` in the signature - that constructs one shared instance at import time. Dependencies then get threaded upward until they are built once, near the program entry point - the place people call a *composition root*. That plumbing is the real cost of injection, and it is what makes the pattern feel heavy in small scripts and pay for itself in anything long-lived. ### When injection is not available Patching still exists for a reason. A module-level function that reaches for a global, a third-party library that constructs its own client, code you are not allowed to change this sprint, an import-time singleton: these have no parameter seam, and `unittest.mock.patch` is the correct tool rather than a defeat. The judgement is about proportion. One patch at the edge of the system is normal; a stack of them over code you own is the code telling you it never offered a seam in the first place. ### The failure mode on the other side Injection can also be overdone. Passing in value objects, standard-library calls, or things with no alternative implementation adds parameters that will never vary and makes every construction site noisier. Inject what has a *second* implementation in some real scenario - typically anything that performs I/O, talks to another process, or is slow or non-deterministic - and construct the rest inline.
- If injection is better, why does unittest.mock.patch exist at all?For the seams you cannot open. Module-level globals, import-time singletons, third-party libraries that construct their own clients, and code you are not free to change have no parameter to pass. Patching is also the pragmatic first move on legacy code: patch to get the behaviour under test at all, then refactor to a parameter and delete the patch. The smell is not one patch, it is a stack of them over code you own.
- Does a hand-written fake in Python need to inherit from the real collaborator's class?No. Duck typing means the object only has to provide the attributes and methods that are actually called. Inheriting from the real class drags its constructor and its I/O into the test, and inheriting from an `abc.ABC` base couples your test doubles to a production hierarchy. If you want the fake checked rather than merely hoped at, type the parameter with a `typing.Protocol` and let a static checker confirm both the real class and the fake match.
- What should a fake deliberately not do?It should not grow business logic. A fake that starts computing totals, validating input, or re-implementing rules becomes a second production implementation that can agree with the tests and disagree with reality. Keep it to recording and returning: a dict, a list, a canned value, and the same exception types the real one raises for the error paths you test.
A hard-wired collaborator is an appliance soldered to the mains; an injected one has a plug. Testing the soldered version means cutting into the wall.
saying these in an interview costs you the question
- Claims a class must build its own dependencies to be encapsulated
- Reaches for patch before checking whether a parameter exists
- Says a fake must subclass the real collaborator
- Uses `store or RealStore()` and misses falsy collaborators
- Injects everything, including value objects with no alternative
- Believes patching is checked by the interpreter like a parameter is