Writing Testable Python Functions from Day One
I tried to add tests to an old project last month. Two hours in, I gave up. The functions grabbed config from global variables, called APIs directly, and depended on the current time. Testing one function meant mocking half the universe.
The code wasn't bad—it worked fine. But it was written without testing in mind, and retrofitting tests was painful. I rewrote 300 lines instead.
Here's what I've learned: code that's hard to test is usually hard to maintain. The practices that make code testable also make it cleaner.
The Core Problem: Hidden Dependencies
A function should be a black box. Data goes in through arguments. Results come out through return values. Simple to understand, simple to test.
Problems start when functions reach outside themselves:
# ❌ Hard to test
import datetime
TAX_RATE = 0.2 # Global variable
def calculate_total(order):
subtotal = sum(item.price for item in order.items)
tax = subtotal * TAX_RATE # Depends on global
if datetime.datetime.now().weekday() == 5: # Depends on system clock
subtotal -= 10 # Saturday discount
return subtotal + tax
To test this, you need to control the global TAX_RATE. Worse, you need to mock the system clock to test the Saturday discount. Tests that depend on what day it is are guaranteed to cause confusion.
Solution: Make Dependencies Explicit
Pass everything the function needs as arguments:
# ✅ Easy to test
def calculate_total(items, tax_rate, is_weekend):
subtotal = sum(item.price for item in items)
tax = subtotal * tax_rate
if is_weekend:
subtotal -= 10
return subtotal + tax
Now testing is trivial:
def test_weekend_discount():
items = [Item(price=100)]
result = calculate_total(items, tax_rate=0.2, is_weekend=True)
assert result == 108 # (100 - 10) + 20% tax
def test_weekday_no_discount():
items = [Item(price=100)]
result = calculate_total(items, tax_rate=0.2, is_weekend=False)
assert result == 120 # 100 + 20% tax
No mocking. No global state manipulation. Just call the function with different inputs.
Dependency Injection
Sometimes you can't avoid external dependencies. Your function needs to send emails or query a database. The key is to inject these dependencies rather than creating them internally.
# ❌ Creates its own dependency
class UserService:
def register(self, email):
client = EmailClient() # Hardcoded dependency
client.send_welcome(email)
# ✅ Dependency injected
class UserService:
def __init__(self, email_client):
self.email_client = email_client
def register(self, email):
self.email_client.send_welcome(email)
In production, pass the real email client. In tests, pass a fake:
class FakeEmailClient:
def __init__(self):
self.sent = []
def send_welcome(self, email):
self.sent.append(email)
def test_register_sends_welcome_email():
fake_client = FakeEmailClient()
service = UserService(fake_client)
service.register("user@example.com")
assert "user@example.com" in fake_client.sent
No actual emails sent. No network calls. Fast, reliable tests.
Separate IO from Logic
A common pattern I see: functions that read data, process it, and write results all in one blob. These are nightmare to test because you need real files.
# ❌ Mixed concerns
def process_csv(input_path, output_path):
with open(input_path) as f:
data = csv.reader(f)
filtered = [row for row in data if row[2] > '1000']
with open(output_path, 'w') as f:
# write filtered data...
Split it:
# ✅ Separated concerns
def filter_high_value(rows):
"""Pure logic - easy to test"""
return [row for row in rows if int(row[2]) > 1000]
def process_csv(input_path, output_path):
"""IO orchestration"""
with open(input_path) as f:
rows = list(csv.reader(f))
filtered = filter_high_value(rows)
with open(output_path, 'w') as f:
writer = csv.writer(f)
writer.writerows(filtered)
Now you can test filter_high_value with simple lists. No files needed. The IO code is thin and obvious—less need for extensive testing.
Avoid Global State
Every time you use the global keyword, a test becomes harder to write. Global state means functions have invisible inputs that change based on what ran before.
If you need configuration, bundle it into an object:
class Config:
def __init__(self, tax_rate, discount_threshold):
self.tax_rate = tax_rate
self.discount_threshold = discount_threshold
def calculate_total(items, config):
subtotal = sum(item.price for item in items)
if subtotal > config.discount_threshold:
subtotal *= 0.9
return subtotal * (1 + config.tax_rate)
Now different tests can use different configs without interfering with each other.
Quick Checklist
Before writing a function, ask:
- Can I test this by just checking the return value?
- Does it read from global variables? (Make them parameters)
- Does it use the current time/date? (Pass it in)
- Does it create its own dependencies? (Inject them)
- Does it mix IO with logic? (Separate them)
Code that's easy to test is code where you control all the inputs. Push the messy real-world stuff to the edges and keep your core logic pure.