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:

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.

← Back to Python Articles

Back to Home