Python Programming — Alpha School

Lesson 7: Debugging Strategies and Testing

🎧 Listen to this lesson

Debugging is not a talent and it is not guessing — it is the scientific method applied to code: reproduce the problem, form a hypothesis, test it, and narrow down where the truth stops matching your expectation. This lesson gives you the professional workflow, shows you how to read a traceback, introduces print debugging and structured testing with assert, and walks through three classic bugs — including one in code that runs perfectly and still lies to you.

Reading a traceback

When Python crashes, it prints a traceback: the chain of calls that led to the failure, ending with the exception type and message. Read it bottom-up — the last line is the verdict, the lines above are the path. This small program crashes:

scores = [70, 80, 90]
print(scores[3])

The traceback ends with IndexError: list index out of range, and just above it, the file name and line number of the failing line. A list of three items has indices 0, 1 and 2, so index 3 does not exist — the error is honest and specific. Beginners often read tracebacks top-down and drown in library internals; professionals jump to the last line, note the exception type and the line in their file, and start there. The common types form a small vocabulary: SyntaxError (the code could not be parsed), NameError (unknown name), TypeError (wrong kind of value), ValueError (right kind, wrong value), IndexError and KeyError (missing position or key), FileNotFoundError (missing file).

The debugging workflow

Four steps, in order. Reproduce: make the bug happen reliably, with the smallest input that triggers it — a bug you cannot reproduce is a bug you cannot verify fixing. Read: the traceback or, if there is no crash, the wrong output — what exactly differs from what you expected? Isolate: shrink the suspect area — comment out half the code, or call the suspicious function alone with known inputs, bisecting until the fault is cornered. Test: print the actual values at the failure point and compare them with the values you assumed. Most bugs are not exotic language behaviour; they are a variable that was not what you thought it was. Print with labels — print("total:", total) — because a screen of bare numbers tells you nothing about which number is which.

Worked example 1 — debugging a wrong average

A function returns suspicious results. Step one, reproduce it with a tiny input whose answer you know:

def avg(nums):
    return sum(nums) / len(nums) - 1

print(avg([10, 20]))

Output: 14.0. The average of 10 and 20 is 15, so the bug reproduces on demand. Step two, isolate with labelled prints inside a copy of the function:

def avg_debug(nums):
    total = sum(nums)
    count = len(nums)
    print("total:", total, "count:", count, "mean:", total / count)
    return total / count - 1

print(avg_debug([10, 20]))

Output:

total: 30 count: 2 mean: 15.0
14.0

The prints prove sum and len are innocent: the mean is a healthy 15.0, and only after it does the value change — the stray - 1 at the end of the return is the culprit, probably a leftover from an earlier version of the formula. Step three, fix it and — the step beginners skip — lock the fix in with tests so the bug can never quietly return:

def avg(nums):
    return sum(nums) / len(nums)

assert avg([10, 20]) == 15
assert avg([5]) == 5
assert avg([2, 4, 6]) == 4
print("All tests passed")

An assert states a fact that must hold: it passes silently or raises AssertionError. The three tests check a normal case, a one-item edge case, and another normal case. Output: All tests passed.

Worked example 2 — the condition that is always True

Not all bugs crash. This one runs happily and does the wrong thing:

answer = "no"
if answer == "yes" or "y":
    print("Starting the game...")
else:
    print("Maybe next time.")

Output: Starting the game... — even though the answer was "no". Read the condition the way Python does: or joins two separate tests, answer == "yes" and "y". The second is not a comparison at all; it is just the string "y", and any non-empty string counts as true. So the condition is False or True — always True, for every possible answer. The fix is to test membership in a set of allowed answers:

answer = "no"
if answer in ("yes", "y"):
    print("Starting the game...")
else:
    print("Maybe next time.")

Output: Maybe next time. — correct. The pattern x == "a" or "b" appears in real code constantly because it reads like English; Python does not speak English, it speaks logic. Whenever a condition uses or with bare values after it, rewrite it with in.

Testing systematically

A test is a call with known input and an expected output. Good tests cover three territories: the happy path (normal input), edge cases (empty, zero, negative, the largest expected value), and error cases (invalid input handled properly). A tiny test-runner shows the shape professionals scale up in Lesson 17:

def is_even(n):
    return n % 2 == 0

tests = [(4, True), (3, False), (0, True), (-2, True), (-3, False)]
for value, expected in tests:
    result = is_even(value)
    status = "PASS" if result == expected else "FAIL"
    print(f"is_even({value}) = {result} (expected {expected}) {status}")

Output:

is_even(4) = True (expected True) PASS
is_even(3) = False (expected False) PASS
is_even(0) = True (expected True) PASS
is_even(-2) = True (expected True) PASS
is_even(-3) = False (expected False) PASS

Notice the cases chosen: zero and negatives are where "even" definitions wobble in beginners' heads, so they are tested deliberately, not hopefully. When a test fails, it tells you the input, the expectation and the reality — which is the reproduce step of debugging, done in advance.

Common mistakes

  • Mutating a list while looping over it. Watch what happens here:
data = [4, -1, -2, 8]
for x in data:
    if x < 0:
        data.remove(x)
print("After the buggy loop:", data)
data = [x for x in [4, -1, -2, 8] if x >= 0]
print("With a comprehension:", data)

Output: After the buggy loop: [4, -2, 8] — the -2 survives! Removing -1 shifted every later item one position left, so the loop stepped past -2 without ever examining it. Then the comprehension version prints With a comprehension: [4, 8] — build a new list instead of editing the one you are walking.

  • Changing code at random. If you cannot say why a change should fix the bug, you are not debugging, you are shuffling. Reproduce first, change one thing at a time.
  • Reading the traceback from the top. The top is ancient history (where the call chain started); the verdict is the last line.
  • Testing only the happy path. [10, 20] passing tells you nothing about [] (which crashes the average with ZeroDivisionError) or negative marks. Edge cases are where bugs live.
  • Print statements without labels. print(x) twenty times produces twenty anonymous numbers. Label every debug print, and remove them when done — or graduate to logging in Lesson 14.
Try it. Try it: A classmate insists if x == "yes" or "y": is fine because "it works when I type yes". Explain, in one sentence, why it also "works" when x is "no", then write the corrected condition. Then write two assert tests for a function first_letter(word) that returns word[0] — one normal case and one edge case you have to think about.

Real-World Challenge

The task. Below is a small shop program with three planted bugs. It runs without crashing and every answer it gives is wrong. Find each bug, explain it, and produce a corrected version with tests.

def total_price(prices):
    total = 0
    for i in range(len(prices) - 1):   # bug 1 hides here
        total += prices[i]
    return total

def average_price(prices):
    return total_price(prices) // len(prices)   # bug 2 hides here

def wants_receipt(answer):
    return answer == "yes" or "y"     # bug 3 hides here

prices = [10, 20, 30]
print("Total:", total_price(prices))
print("Average:", average_price(prices))
print("Receipt?", wants_receipt("no"))

The buggy program's output:

Total: 30
Average: 10
Receipt? y

Worked solution. Bug 1 is an off-by-one: range(len(prices) - 1) stops one early, so the last price (30) is never added — the total is 30 instead of 60. Bug 2 is integer division: // discards the fraction, and it also inherits bug 1's wrong total, giving 10 instead of 20. Bug 3 is the or trap from Worked example 2 in a new costume: answer == "yes" or "y" never returns False at all — when the comparison fails, or hands back the string "y" itself, which is why the buggy run printed the baffling Receipt? y. A truthy string is not True, and it is certainly not a receipt decision. The corrected program loops over values directly (no index to get wrong), divides with /, tests membership with in — and normalises case while it is there, because a human will type "YES":

def total_price(prices):
    total = 0
    for price in prices:
        total += price
    return total

def average_price(prices):
    return total_price(prices) / len(prices)

def wants_receipt(answer):
    return answer.strip().lower() in ("yes", "y")

prices = [10, 20, 30]
print("Total:", total_price(prices))
print("Average:", average_price(prices))
print("Receipt?", wants_receipt("no"))
assert total_price(prices) == 60
assert average_price(prices) == 20
assert wants_receipt("no") == False
assert wants_receipt("YES") == True
print("All bug-hunt tests passed")

Output:

Total: 60
Average: 20.0
Receipt? False
All bug-hunt tests passed

How it works. Notice that the asserts are not decoration: each one pins a behaviour the buggy version violated — the full total, the true average, a "no" that means no, and a capitalised "YES" that still means yes. If any bug creeps back in a future edit, a test names it immediately. That is the entire philosophy of this lesson in one program: reproduce, isolate, fix, and then write the test that proves it stays fixed.

Extension. The fixed average_price still crashes on an empty price list. Decide what it *should* do — return 0, or raise a clear ValueError with a helpful message — implement your decision, and add a test that proves the behaviour. There is no single right answer; there is only a decision you can defend and a test that enforces it.

Going further — assertions as tripwires

An assert states something your program believes; if the belief is false, execution stops immediately with an AssertionError at the exact line whose assumption broke. Sprinkled at the borders of functions, asserts convert silent wrong answers into loud, locatable failures while you develop:

def double(n):
    return n * 2

assert double(3) == 6
assert double(-1) == -2
assert double(0) == 0
print("double() checks passed")

Output: double() checks passed. The three cases are chosen like test cases (Lesson 17 grows this into a discipline): a positive, a negative, and the zero edge where sign logic so often hides. Two cautions keep asserts honest. They can be switched off globally (running Python with -O strips them), so never use an assert for validation the *user's* input needs — that is what if-statements and exceptions are for. And an assert without a message in a big program can be cryptic; assert total >= 0, f"negative total {total}" tells the story at a glance. Used as tripwires for "this should be impossible", they are the cheapest debugging tool in the language.

🎬 Watch & Learn

Prefer video? These searches surface top tutorials on this lesson's topic:

💪 Practice Zone

Try these yourself — hints are included, answers are not. Run your code in any Python environment.

  1. Find the bug — This should print 1–5 but doesn't. Diagnose it before fixing:
    for i in range(5):
        print(i)
    HintIt's an off-by-one error. What values does range(5) actually produce? How would you get 1–5?
  2. Trace with prints — Take any function you wrote this week, add temporary print() calls showing each variable's value, run it, then remove them.
    HintPrint at the top of the function (inputs) and right before each return (outputs).
  3. Three asserts — Write double(n) returning n * 2, then write three assert statements testing it — including one edge case.
    HintExample: assert double(0) == 0. Edge cases: 0, negatives, large numbers.
  4. Rubber-duck it — Pick the trickiest bug you've hit so far. Explain it out loud (to a duck, a friend, or yourself) line by line, then write down what the explanation revealed.
    HintSaying what you expect each line to do vs. what it actually does is where the bug hides.

🌍 Real World

Software teams employ QA engineers whose whole job is this lesson: reproduce bugs, write tests, and stop broken code from reaching users. Every app update on your phone passed through automated tests like the assert statements you wrote — testing is a career, not just a chore.

Keep reading to complete this lesson