Testing, Debugging & Profiling
pytest patterns, fixtures, and finding the slow line with a profiler instead of guessing. Confidence to change code fast.
Tests are executable confidence. They let you change code quickly because the important behavior is pinned down. Debugging tells you why a specific case fails. Profiling tells you where time actually goes before you optimize the wrong line.
Those three habits belong together. Write tests for behavior, drop into the debugger when your mental model disagrees with reality, and profile before you reach for clever performance tricks.
The mental model
A good test has three parts: arrange the world, act on the unit under test, and assert the observable result.
def normalize(text: str) -> str:
return " ".join(text.lower().split())
def test_normalize_collapses_whitespace():
result = normalize(" Hello WORLD ")
assert result == "hello world"
Pytest finds functions named test_*, runs them, and rewrites assert failures so you see useful diffs. You do not need a test class for simple cases.
Pytest basics
- ▲Unit testsmany · milliseconds · pure logic
- ◆Integration testssome · seconds · real boundaries
- ●End-to-end testsfew · slow · full system
Prefer direct assertions over vague truthiness:
assert user.email == "ada@example.com"
assert response.status_code == 201
assert "timeout" in str(exc.value)
Use pytest.raises for expected failures:
import pytest
def test_parse_rejects_empty_input():
with pytest.raises(ValueError, match="empty"):
parse_config("")
The test should read like a small example of the behavior, not a transcript of the implementation.
Fixtures and parametrization
A fixture is reusable setup. If it uses yield, code after the yield is teardown and runs after the test.
import pytest
@pytest.fixture
def user_store():
store = InMemoryUserStore()
yield store
store.clear()
Parametrization runs the same test over several inputs without copy-paste:
@pytest.mark.parametrize(
("raw", "expected"),
[("Hello", "hello"), ("two words", "two-words")],
)
def test_slugify(raw, expected):
assert slugify(raw) == expected
Use both to keep tests small. Repeated setup belongs in a fixture; repeated cases belong in parametrize.
Mock boundaries, not internals
Mock the boundary you do not own: network calls, clocks, random IDs, object storage, payment providers, model APIs. Do not mock every helper function inside your own module; that locks the test to the implementation.
from unittest.mock import Mock
def test_index_calls_embedder():
embedder = Mock()
embedder.embed.return_value = [0.1, 0.2]
index_one("hello", embedder)
embedder.embed.assert_called_once_with("hello")
A fake object is often clearer than a mock when behavior matters. Use mocks to verify boundary calls; use fakes to exercise real flows.
Debugging with breakpoint()
When a failing test surprises you, insert breakpoint() near the failure and run that one test. Python drops into pdb so you can inspect variables, step through code, and test expressions in the live frame.
def test_ranking_order():
result = rank_documents("ai", docs, model)
breakpoint()
assert result[0].id == "best"
Useful commands: p variable, n for next line, s to step into, c to continue, q to quit. Remove the breakpoint before committing.
Profiling before optimizing
Use timeit for small expressions and cProfile for whole flows. The first question is not “How do I make this faster?” It is “Where is the time going?”
import cProfile
import pstats
def profile_run():
profiler = cProfile.Profile()
profiler.enable()
run_pipeline()
profiler.disable()
pstats.Stats(profiler).sort_stats("cumtime").print_stats(10)
Look at cumtime to find functions that are expensive including their children. Look at tottime to find functions whose own body is expensive. Optimize the hot path that matters, then measure again.
When to reach for this
Variations
Try it
These exercises are the daily workflow: express behavior, manage setup, and read a profile without guessing.
Write a parametrized test
bucket_latency(ms) should return "fast" for values under 100, "ok" for values under 500, and "slow" otherwise. Write one parametrized test that covers the boundaries.
Approach. Put the inputs and expected outputs in the decorator. Boundary values matter more than random examples.
Show solution
import pytest
def bucket_latency(ms: int) -> str:
if ms < 100:
return "fast"
if ms < 500:
return "ok"
return "slow"
@pytest.mark.parametrize(
("ms", "expected"),
[
(0, "fast"),
(99, "fast"),
(100, "ok"),
(499, "ok"),
(500, "slow"),
],
)
def test_bucket_latency(ms: int, expected: str) -> None:
assert bucket_latency(ms) == expectedOne test body now documents the full rule. If the threshold changes, the expected values are visible in one small table.
Create a fixture with teardown
You need a fresh in-memory queue for each test, and you want to assert it is empty after the test finishes. Write a fixture that sets it up and tears it down.
Approach. A yield fixture returns the object to the test. Code after yield runs as teardown even if the test fails.
Show solution
import pytest
class Queue:
def __init__(self):
self.items = []
def push(self, item):
self.items.append(item)
def pop(self):
return self.items.pop(0)
def clear(self):
self.items.clear()
@pytest.fixture
def queue():
q = Queue()
yield q
try:
assert q.items == []
finally:
q.clear()
def test_queue_round_trip(queue):
queue.push("job-1")
assert queue.pop() == "job-1"Each test gets its own Queue, so tests cannot leak state into one another. The teardown is also the right place to close files, roll back transactions, or stop local fakes.
Read a cProfile hot spot
A profile sorted by cumulative time shows this excerpt:
ncalls tottime cumtime function
200 0.040 4.820 embed_batch
40000 3.950 3.950 normalize_token
200 0.210 0.620 json.loadsWhich function should you inspect first, and how would you isolate it with a small profiler run?
Approach. embed_batch has the largest cumulative time because it calls the pipeline. normalize_token has the largest total time in its own body, so it is the hot line to inspect first.
Show solution
import cProfile
import pstats
def profile_normalization(tokens: list[str]) -> None:
profiler = cProfile.Profile()
profiler.enable()
for token in tokens:
normalize_token(token)
profiler.disable()
stats = pstats.Stats(profiler).sort_stats("tottime")
stats.print_stats(5)Start with normalize_token: it accounts for most of the self time. After you change it, rerun the same profile. If embed_batch remains slow, inspect the next largest tottime or boundary call rather than guessing.