Refactoring for Testability: Enhancing Python Code with Dependency Injection and Pytest
Refactoring for Cleaner Test Design
Writing unit tests for legacy or poorly structured code often feels like a battle against the machine. When a function creates its own dependencies internally, testing that function requires heavy-handed monkey patching and complex mocking strategies. This brittle approach makes tests hard to maintain and even harder to read. The solution isn't just better mocks; it's better code design. By refactoring our functions to be more testable, we simultaneously improve the architecture of our entire application.
Implementing Dependency Injection and Protocols
The most effective way to break tight coupling is . Instead of a function instantiating a inside its body, we pass the processor as an argument. This shift gives the caller—and the test suite—full control over the implementation.
To keep things flexible, we define a using 's typing module. This allows us to use duck-typing to create a mock version of the processor that behaves like the real thing without requiring complex inheritance.
from typing import Protocol
class PaymentProcessor(Protocol):
def charge(self, card: CreditCard, amount: int) -> None:
...
def pay_order(order: Order, processor: PaymentProcessor, card: CreditCard):
if not order.line_items:
raise ValueError("Order is empty")
processor.charge(card, order.total_price)
Streamlining Tests with Pytest Fixtures
When multiple tests require the same setup—like a valid object—redundancy creeps in. fixtures solve this by providing standard, reusable objects to your test functions. We can also make these fixtures "future-proof" by calculating dates dynamically. Hard-coding an expiry date of 2024 might work today, but it ensures your tests will break the moment that year passes.
import pytest
from datetime import date
@pytest.fixture
def card():
future_year = date.today().year + 2
return CreditCard(number="4111...", expiry_month=12, expiry_year=future_year)
def test_pay_order_valid(card, processor_mock):
# The card is automatically injected by pytest
pay_order(my_order, processor_mock, card)
Handling Sensitive Data and Pure Functions
Hard-coding API keys is a major security risk and a testing headache. Move these to environment variables using . This keeps secrets out of your repository and allows different keys for development, testing, and production.
Finally, simplify your logic by identifying functions that don't need to be tied to a class. A validation utility like the luhn_checksum doesn't need self. Converting it to a standalone pure function makes it trivial to test in isolation without instantiating a heavy processor object. This separation of concerns is the hallmark of professional software development.
- 14%· products
- 14%· concepts
- 14%· concepts
- 14%· concepts
- 14%· libraries
- Other topics
- 29%

How To Write Unit Tests For Existing Python Code // Part 2 of 2
WatchArjanCodes // 17:18
On this channel, I post videos about programming and software design to help you take your coding skills to the next level. I'm an entrepreneur and a university lecturer in computer science, with more than 20 years of experience in software development and design. If you're a software developer and you want to improve your development skills, and learn more about programming in general, make sure to subscribe for helpful videos. I post a video here every Friday. If you have any suggestion for a topic you'd like me to cover, just leave a comment on any of my videos and I'll take it under consideration. Thanks for watching!