Testable Python Code without Mocks and Patches
Unit Testing Should Be Easy and Fun
Unit testing is where you play with your creation, by plugging in different dependencies (DB, API, etc), inputs, and outputs, and seeing how your code behaves. You discover bugs faster with failed tests. You understand your creation better with the help of debuggers. And you get a dopamine hit when all tests pass with the green check mark.
But Quite Often, It’s Not
The common complaints tend to fall in a chain:
“It’s hard to plug in the dependency and test data.”
→ “It’s hard to understand the mocks and patches.”
→ “It’s hard to change the code without breaking tests.”
→ “It’s hard to debug and fix the broken tests.”
These aren’t isolated issues. They reflect a deeper causal chain:
Testing as an afterthought
→ Untestable code
→ Complex tests tightly coupled with implementation details
What’s the Deal with Mocks and Patches?
Mocks are test doubles that record how they’re called, i.e. what arguments, how many times, in what order. The recorded calls are asserted against expectations.
Patches dynamically overwrite real functions or objects at runtime, for example with mocks.
These are powerful tools in Python. They enable you to test the otherwise untestable code. But untestable code should be exceptions, not the normal. Abusing mocks and patches leads to tests that are coupled with the implementation details.
This creates brittle tests: one innocent change in the implementation breaks tests.
This creates unreadable tests: the interface of the function-under-test tells you little about how this test really works.
This snowballs tech-debt: we stop continuous refactoring and accumulate bad designs over time, eventually slow down the shipment of new features.
Okay, but how else can we “mock“ out the real API calls?
Fakes are lightweight implementations that behave like the real thing but work entirely in-memory. Testable code allows you to easily plug in a fake in place of a dependency that would have done real API calls. Because fakes test the outcomes instead of call patterns, they are more robust, readable, and aligned with real usage.
Write Code with Testing as a Forethought
The key is to design your code in a way that makes testing easy. Easy test means it’s easy to spot and plug in the fake dependency, test input, and test output. Think of testability as a design constraint, not something you retrofit later.
Here are four simple patterns to guide that:
Separate I/O from business logic
Don’t mix fetching, saving, and computing in the same function. Separate them cleanly.
(Single Responsibility Principle, Separation of Concern)Declare dependencies explicitly
Don’t hide them inside functions. Pass them in via function arguments.
(Inversion of Control, Separation of Concern)Depend on abstractions, not concrete implementations
Don't call raw APIs or DB clients directly. Use a thin adapter layer.
(Dependency Inversion Principle, Hexagonal Architecture)Pull I/O to the top-level
Don’t bury API calls or file reads deep in the call stack. Keep them near the boundary of your system.
(Functional Core, Imperative Shell)
In fact, these patterns help produce more high-quality code that’s naturally multi-purpose and easier to maintain and adapt. The improved testability is only a by-product.
Know the Tradeoffs
The above practices introduce extra indirections that can reduce readability. Designing testable code takes time. Dependency injection without a robust interface design can bloat function signatures and lead to dependency chains that go 10 layers deep.
Patterns like Functional Core Imperative Shell may also force you to rethink your natural task decomposition. Pulling side effects up to the top-level sometimes requires reversing the intuitive flow of logic, which can make the code less straightforward.
So no silver bullets. Just tradeoffs. And discipline.
Let Tests Be the Mirror
Let unit tests guide your structure. When they’re painful to write, hard to change, and fragile to run, take a step back. That pain is often a design smell, not just a tooling problem.
And when testing is easy, something clicks. You refactor without fear. You ship with confidence. And coding starts to feel like playing again.