AI SWE Prep
0/9in Python Foundations
Python Foundations

Testing, Debugging & Profiling

pytest patterns, fixtures, and finding the slow line with a profiler instead of guessing. Confidence to change code fast.

~13 minLesson 11 of 60
Your progressNot started

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

The test pyramid
  1. Unit testsmany · milliseconds · pure logic
  2. Integration testssome · seconds · real boundaries
  3. End-to-end testsfew · slow · full system
Push coverage down the pyramid: fast feedback where bugs are cheapest.

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.

ExerciseEasy
  • pytest
  • parametrize

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) == expected

One test body now documents the full rule. If the threshold changes, the expected values are visible in one small table.

ExerciseMedium
  • pytest
  • fixture

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.

ExerciseMedium
  • cProfile
  • Profiling

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.loads

Which 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.

The classic pitfall

Your progressNot started