Learn / Programming / Python / Testing and Debugging

Python · Lesson 15 of 15

Testing and Debugging

Write tests with pytest, read tracebacks, and track down bugs with print and the debugger.

  • Intermediate
  • 15 min read
  • 3 objectives

Before this lessonLesson 14: JSON, Dates and the Standard Library

What you will learn

  • Write and run pytest tests
  • Test error cases
  • Debug with breakpoint()

Every program has bugs; the skill is finding them quickly and making sure they stay fixed. Tests are small programs that check your code still does what you think. Debugging is the detective work when it does not. Learning both early will save you far more time than they cost.

Reading a traceback

When an exception is not handled, Python prints a traceback: the chain of calls that led to the error. Read it from the bottom up. The last line is the error type and message; the line above it is where it happened; the lines above that show how you got there.

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

print(average([]))
Output
Traceback (most recent call last):
  File "app.py", line 4, in <module>
    print(average([]))
  File "app.py", line 2, in average
    return sum(nums) / len(nums)
ZeroDivisionError: division by zero

The message tells you exactly what went wrong: len(nums) was zero because the list was empty. The fix is a decision for you: return 0, raise a clearer error, or make callers check first.

Your first test with assert

An assert statement checks a claim and raises AssertionError if it is false. It is the simplest possible test: call your function with known input and assert the answer you expect.

def is_palindrome(text):
    cleaned = "".join(ch.lower() for ch in text if ch.isalnum())
    return cleaned == cleaned[::-1]


assert is_palindrome("Racecar")
assert is_palindrome("A man, a plan, a canal: Panama")
assert not is_palindrome("python")
print("all checks passed")
Output
all checks passed

Real tests with pytest

pytest is the most popular test runner. Install it with pip install pytest, put tests in files named test_*.py, and name each test function test_*. Run pytest and it finds and runs them all, showing exactly which assertion failed and with what values.

# test_text.py
from text import is_palindrome

def test_simple_palindrome():
    assert is_palindrome("level")

def test_ignores_case_and_punctuation():
    assert is_palindrome("A man, a plan, a canal: Panama")

def test_not_a_palindrome():
    assert not is_palindrome("python")
pip install pytest
pytest -v
Output
test_text.py::test_simple_palindrome PASSED
test_text.py::test_ignores_case_and_punctuation PASSED
test_text.py::test_not_a_palindrome PASSED

3 passed in 0.02s

Testing the error cases

Good tests cover the awkward inputs too: empty values, zero, negatives, missing data. When a function is supposed to raise, use pytest.raises to assert that it does. Use parametrize to run the same test over many inputs without copy and paste.

import pytest

def safe_divide(a, b):
    if b == 0:
        raise ValueError("b must not be zero")
    return a / b

def test_divides():
    assert safe_divide(10, 4) == 2.5

def test_zero_raises():
    with pytest.raises(ValueError):
        safe_divide(1, 0)

@pytest.mark.parametrize("a, b, expected", [(6, 3, 2), (9, 3, 3), (1, 4, 0.25)])
def test_many(a, b, expected):
    assert safe_divide(a, b) == expected

Debugging: print first, then the debugger

The fastest first move is a well-placed print showing the values you assumed. Use repr() or the f-string = shortcut so you can see quotes and types. When that is not enough, drop in breakpoint(): execution pauses there and opens an interactive debugger (pdb).

items = [3, 8, 2]
total = 0
for n in items:
    total += n
    print(f"{n=} {total=}")
Output
n=3 total=3
n=8 total=11
n=2 total=13
n  next line           c  continue to next breakpoint
s  step into a call    p x  print the value of x
l  list source code    q  quit the debugger

A simple debugging routine

  • Reproduce it: find the smallest input that triggers the bug and write it down, ideally as a failing test.
  • Read the traceback bottom-up before touching any code.
  • Check your assumptions with a print or breakpoint: is this variable really what you think it is?
  • Change one thing at a time, and re-run after each change.
  • Keep the test once fixed so the bug cannot quietly come back.
# Write your solution here
Course completeYou finished PythonReview the full course or pick your next one.