Fifteen unittest.mock.patch decorators stack on one test for an invoice-PDF renderer. What does that say about the design?
answer
- Count the patches, read them as dependencies
- The seam of last resort
- Tests pinned to the import graph
- Hoist construction, pass collaborators in
- Still fifteen parameters? split the unit
basics
~20 sIt says the renderer builds or imports all fifteen collaborators itself, leaving the test no way in but the module namespace. Pass them in instead, swap the patches for two or three fakes, and split the class if the count stays high.
solid answer
~50 sA patch stack is a dependency list written in the wrong place. Every decorator marks something the code reaches out and grabs for itself, so the test has to know the renderer's import graph and is coupled to structure rather than behaviour: rename a module and the tests go red without any behaviour changing. The doubles are unchecked too, unless built with `unittest.mock.create_autospec`, so a signature change in production leaves the test green. I would group the patches by collaborator, hoist construction to the caller, and pass in two or three objects with hand-written fakes in the test. If the count stays high after that, the unit is the problem - a renderer in front of a 17-service dependency graph should talk to a couple of facade collaborators, not seventeen clients. A residual patch at an edge you do not own is fine.
code
python · 31 linesclass Renderer:
def __init__(self, invoices, archive):
self.invoices = invoices # facade over the upstream services
self.archive = archive # facade over storage
def render(self, invoice_id):
data = self.invoices.fetch(invoice_id)
pdf = b"%PDF-1.7 " + repr(data).encode()
self.archive.save(invoice_id, pdf)
return len(pdf)
class FakeInvoices:
def __init__(self, rows):
self.rows = rows
def fetch(self, invoice_id):
return self.rows[invoice_id]
class FakeArchive:
def __init__(self):
self.saved = {}
def save(self, invoice_id, pdf):
self.saved[invoice_id] = pdf
archive = FakeArchive()
Renderer(FakeInvoices({"INV-17": {"total": 42}}), archive).render("INV-17")
print(sorted(archive.saved))go deeper
Recognise the shape: many patches means the code builds its own collaborators. Know that passing an object in is the alternative, even if you have not yet done the refactor yourself.
Explain why patch targets couple tests to the import graph and why an unspecced double keeps passing after a signature change. Show the concrete conversion of one patched dependency into a constructor parameter with a fake.
Demonstrate the ordered, incremental refactor and the judgement about which dependencies deserve seams. Say clearly when the real answer is that the unit is too big rather than that the tests are wrong, and which patches you would keep.
Own the direction: how you would measure the coupling, sequence the work against delivery, set the convention for new code, and keep a team from swapping one dogma for another. Be explicit about the cost of the refactor and what it buys.
A tall stack of patch decorators is one of the most reliable design signals in a Python codebase, because patching is what you are forced to do when nothing else is available. ### Read the stack as a dependency list Every `unittest.mock.patch` line names something the code under test acquires by itself: it imports a module and calls into it, constructs a client in `__init__`, or reads a module-level global. If a collaborator were a parameter, the test would simply pass an object; the patch exists precisely because there is no parameter. So the stack is an accurate, if unflattering, inventory of the unit's hard-wired dependencies - and fifteen of them says this one unit is a hub. ### What that costs the test suite **Coupling to structure.** A patch target is a string naming *where a name is looked up*. Tests written this way encode which module imports what. Move a helper, change an import to a package-relative one, or introduce an indirection, and tests fail for a refactor that changed no behaviour. That is the definition of tests that resist change. **Doubles that agree with anything.** A default mock accepts any attribute and any call. Rename a production method or add a required parameter and the test still passes - it is asserting against a shape that no longer exists. `unittest.mock.create_autospec` closes most of that gap by building the double from the real object's signature, and a patch-heavy suite that does not use it is running on false confidence. **Unreadable tests.** Fifteen decorators means fifteen injected mock parameters in reverse order in the signature, and an arrange block longer than the code under test. Nobody can tell from reading it what behaviour is being asserted. **A slow feedback loop on design.** The suite hides the coupling instead of reporting it. The tests still pass, so nothing forces the conversation. ### The refactor, in order 1. **Inventory.** List the patch targets and group them by collaborator. Fifteen patches are usually four or five real dependencies plus their incidental helpers. 2. **Find the real boundaries.** Which of those cross a process, do I/O, or are non-deterministic? Those need seams. Pure helpers do not - stop patching them and let them run. 3. **Hoist construction.** Change the unit to take each remaining collaborator as a constructor parameter, defaulting to `None` and resolving to the real implementation in the body so existing call sites keep working. Build the objects once at the entry point instead. 4. **Write fakes.** Replace each patch with a small in-memory class - a dict and a couple of methods - and assert against its state rather than against call records. 5. **Type the seams.** Annotate the parameters with narrow `typing.Protocol` shapes so a static checker keeps the fakes honest as the real implementations change. 6. **Re-measure.** If the constructor now takes fifteen parameters, injection did not fix anything: the unit does too much. Collapse related dependencies behind two or three coarse collaborators - one facade per subsystem - or split the unit along the same lines. A renderer that legitimately needs data from a 17-service dependency graph should be handed a prepared invoice object, not seventeen clients. Do it incrementally. Convert one collaborator, delete the patches that covered it, keep the suite green, repeat. Rewriting the whole test module in one change is how this refactor gets abandoned halfway. ### What legitimately stays patched Patching is not a defeat everywhere. Third-party code that constructs its own client, an import-time global you cannot move yet, a platform call with no parameter form: those keep a patch, and that patch is now visible and few instead of buried in a stack of fifteen. Non-deterministic sources such as the clock and the random generator have their own idiomatic seams, which is a separate discussion from this one. ### Saying it in an interview The answer that lands is not "patching is bad". It is: patching is the seam of last resort, the count of patches is a measurement of how few seams the design offers, and the response is to add the seams the code should have had - while being explicit that some edges will always be patched, and that the fix is applied incrementally to a suite people still have to run tomorrow.
- Is the patch count the metric you would actually track?As a trend, not as a target. Counting patch decorators per test module is cheap to automate and highlights the hubs worth refactoring, but the moment it becomes a target people satisfy it by hiding patches in helpers. I would use it to pick the next refactor and watch that new modules do not enter the codebase near the top of the list.
- The team says the refactor is too risky because the patch-heavy tests are all they have. How do you proceed?Incrementally, one collaborator at a time, keeping the existing tests green as the safety net. Add the parameter with a default that resolves to the current behaviour, so production call sites do not change; write the new test against the fake; delete only the patches that collaborator required. Each step is reversible and reviewable, and the suite never goes dark.
- What is left in the test after the refactor that a mock was doing?Usually the interaction assertions. A fake records state, so most assertions become state checks - what ended up in the archive - which are more robust than call-argument checks. Where the interaction genuinely is the behaviour, such as verifying a retry happened, the fake can count calls itself, which keeps the assertion in one obvious place instead of on a generic double.
Fifteen patches is fifteen holes drilled into a sealed box to reach the wiring. The answer is not neater holes; it is a box with connectors.
saying these in an interview costs you the question
- Treats the patch stack as normal Python testing style
- Answers only 'mock less' with no refactor path
- Builds unchecked mocks and never uses autospec
- Proposes rewriting the whole suite in one change
- Adds fifteen constructor parameters and calls it fixed
- Claims patching should be eliminated entirely