Some functions are a joy to test. You call them with an input, check the output, done. Others need a database, a mocked clock, three patched globals, and a prayer. The difference between the two almost always comes down to one property: purity.
A function is pure when it satisfies two rules:
- Same input, same output. Call it with the same arguments and you always get the same result.
- No side effects. It doesn't change anything outside itself - no writing to disk, no mutating a global, no network calls, no printing.
That's it. A pure function is a plain mapping from inputs to an output, and nothing else happens.
An impure function, and why it's annoying to test
Here's a function that decides whether a subscription trial has expired:
from datetime import datetime
def is_trial_expired(user):
now = datetime.now()
days_used = (now - user.trial_started).days
return days_used > 14It looks harmless, but it reads the clock inside itself. That makes it impure - call it twice on different days and the same user gives different answers. To test it you now have to control time, which means patching datetime:
from unittest.mock import patch
def test_trial_expired():
with patch("mymodule.datetime") as mock_dt:
mock_dt.now.return_value = datetime(2026, 1, 20)
user = User(trial_started=datetime(2026, 1, 1))
assert is_trial_expired(user) is TrueEvery test needs that awkward patch dance. And patching ties your test to the exact import path of datetime, so the test breaks if you move the code.
Make it pure: pass the world in
The hidden dependency here is "now." Instead of reaching out and grabbing it, make it an argument:
def is_trial_expired(trial_started, now):
days_used = (now - trial_started).days
return days_used > 14The function no longer knows what today is, and it no longer cares. It just does arithmetic on two values you hand it. The test becomes boring, which is exactly what you want a test to be:
def test_trial_expired():
started = datetime(2026, 1, 1)
assert is_trial_expired(started, now=datetime(2026, 1, 20)) is True
assert is_trial_expired(started, now=datetime(2026, 1, 10)) is FalseNo patching, no mocks, no import-path coupling. Two values in, one boolean out. You can check the boundary at exactly 14 days as easily as any other case, because you fully control every input.
Why purity makes testing trivial
No setup. A pure function's entire universe is its arguments. There's no database to seed, no environment to configure, no global to reset between tests. You construct inputs and assert on the output.
Deterministic by definition. The number-one cause of flaky tests is hidden nondeterminism - the clock, random numbers, ordering, external state. Pure functions have none of it, so a passing test stays passing.
Trivial edge cases. Want to test the leap-year boundary, the empty list, the negative number? Just pass it. You never have to contort the outside world into the state you need.
The same trick for randomness
Time isn't the only hidden dependency. Randomness is the other big one:
import random
def assign_bucket(user_id):
return "A" if random.random() < 0.5 else "B" # untestableSame fix - inject it:
def assign_bucket(user_id, roll):
return "A" if roll < 0.5 else "B"Now assign_bucket("u1", roll=0.3) is a fact, not a coin flip. The caller at the edge of your program is where the real random.random() or datetime.now() lives.
You can't make everything pure, and shouldn't try
A program that never touches the outside world does nothing useful. Something has to read the database, call the API, write the file, print the result. The goal isn't to eliminate side effects - it's to push them to the edges.
Keep a thin impure shell that gathers the real inputs (reads the clock, hits the network, loads the row) and hands them to a pure core that does the actual thinking. Then the interesting logic - the part with bugs worth catching - is pure and easy to test, and the impure shell is thin enough to check with a handful of integration tests.
def handle_request(user, clock): # impure shell, thin
expired = is_trial_expired( # pure core, well tested
user.trial_started, now=clock.now()
)
if expired:
send_upgrade_email(user) # side effect, at the edgeThe takeaway
You don't need a functional language or a new paradigm to get most of the benefit. Just notice when a function reaches out for something - the clock, a random number, a global, the network - and hand it that thing as an argument instead. The function gets predictable, your tests lose their mocks, and the logic you most want to trust becomes the logic that's easiest to verify.

