Testing and Debugging in Python
In this lesson: Find and fix bugs in Python — print debugging, pdb, assert and unittest basics with examples. In Hindi & English.
Why testing and debugging matter
Debugging is finding out why a program behaves wrongly. Testing is writing small programs that check your code automatically, so the same bug never comes back. Professional developers spend more time on these two skills than on writing new code.
Step 1: read the traceback from the bottom
def average(marks):
return sum(marks) / len(marks)
def report(student):
avg = average(student["marks"])
return f"{student['name']}: {avg:.1f}"
print(report({"name": "Asha", "marks": [91, 88]}))
print(report({"name": "Ravi", "marks": []}))- Last line = error type and message:
ZeroDivisionError: division by zero. - Line above it = the exact line that failed (inside
average). - Read upwards to see who called it:
report→ line 9 of your script. - Ask: what value made this fail? Here
markswas an empty list.
| Error | Usual cause |
|---|---|
NameError | Spelling mistake or variable used before assignment |
TypeError | Wrong type, e.g. "5" + 3 or missing argument |
KeyError / IndexError | Key not in dict / index past the end of a list |
AttributeError | Calling a method on the wrong object, often None |
IndentationError | Mixed tabs/spaces or missing indent |
Step 2: print() vs logging
print is fine for a quick check, but in real programs use logging: you can switch messages on/off by level, add time stamps and write to a file – without deleting lines later.
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(funcName)s: %(message)s")
log = logging.getLogger(__name__)
def apply_discount(price, percent):
log.debug("raw input price=%r percent=%r", price, percent) # hidden at INFO level
if not 0 <= percent <= 50:
log.warning("discount %s%% looks wrong, using 0", percent)
percent = 0
final = round(price * (100 - percent) / 100, 2)
log.info("final price %s", final)
return final
apply_discount(1200, 10)
apply_discount(1200, 90)| Level | Use for |
|---|---|
| DEBUG | Detailed values while developing |
| INFO | Normal milestones (“order saved”) |
| WARNING | Something odd but the program continues |
| ERROR | An operation failed |
| CRITICAL | The program cannot continue |
Step 3: pause the program with breakpoint()
Put breakpoint() on any line. When Python reaches it, the program pauses and opens the debugger in the terminal. You can print variables, run the next line and continue.
def grade(marks):
total = sum(marks)
breakpoint() # execution stops here
percent = total / len(marks)
return "A" if percent >= 85 else "B"
print(grade([91, 88, 79]))| Command | Meaning |
|---|---|
p x | print the value of x |
n | run the next line |
s | step into a function call |
l | show code around the current line |
c | continue until the next breakpoint |
q | quit |
In VS Code click left of a line number to set a red breakpoint, then press F5 → “Python File”. The Variables panel shows every value and you can step with F10/F11 – no commands to remember.
Step 4: assert – the smallest test
def to_paise(rupees):
return round(rupees * 100)
assert to_paise(10) == 1000
assert to_paise(0.1 + 0.2) == 30 # floats are tricky – round() saves us
assert to_paise(99.99) == 9999
print("all checks passed")If an assert condition is false, Python stops with AssertionError. Use asserts in tests and for “this should never happen” checks – not for validating user input (asserts can be disabled with python -O).
Step 5: automated tests with pytest
Here is a small billing function for a shop. The rule says: coupon SAVE10 works on bills of ₹500 or more, delivery is free from ₹1,000.
def bill_total(items, coupon=None):
"""items: list of (price, qty). SAVE10 gives 10% off on bills of Rs 500 or more."""
subtotal = sum(price * qty for price, qty in items)
if coupon == "SAVE10" and subtotal > 500:
subtotal = subtotal * 0.9
delivery = 0 if subtotal >= 1000 else 40
return round(subtotal + delivery)Tests are normal functions whose names start with test_, in files named test_*.py. Install pytest once (pip install pytest) and write:
import pytest
from bill import bill_total
def test_small_order_pays_delivery():
assert bill_total([(100, 2)]) == 240
def test_free_delivery_from_1000():
assert bill_total([(500, 2)]) == 1000
def test_coupon_on_exactly_500():
assert bill_total([(250, 2)], coupon="SAVE10") == 490 # 450 + 40 delivery
@pytest.mark.parametrize("items, expected", [
([], 40),
([(99.5, 2)], 239),
([(300, 1), (200, 1)], 540),
])
def test_many_cases(items, expected):
assert bill_total(items) == expectedOne test fails, and pytest shows exactly why: for ₹500 with the coupon we expected 490 but got 540. The bug is subtotal > 500; the rule says “500 or more”. Change it to >= 500 and run again:
def bill_total(items, coupon=None):
"""items: list of (price, qty). SAVE10 gives 10% off on bills of Rs 500 or more."""
subtotal = sum(price * qty for price, qty in items)
if coupon == "SAVE10" and subtotal >= 500:
subtotal = subtotal * 0.9
delivery = 0 if subtotal >= 1000 else 40
return round(subtotal + delivery)Bugs live at the edges: empty lists, exactly 500, exactly 1,000, decimals, zero, negative numbers. A good test suite checks each boundary once.
Testing that errors are raised
import pytest
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("amount must be positive")
if amount > balance:
raise ValueError("insufficient balance")
return balance - amount
def test_normal_withdraw():
assert withdraw(1000, 300) == 700
def test_overdraw_is_blocked():
with pytest.raises(ValueError, match="insufficient"):
withdraw(100, 500)Built-in alternative: unittest
import unittest
def is_valid_pin(pin):
return len(pin) == 6 and pin.isdigit() and pin[0] != "0"
class PinTests(unittest.TestCase):
def test_valid(self):
self.assertTrue(is_valid_pin("302001"))
def test_invalid(self):
for pin in ["02001", "0123456", "30200A", ""]:
with self.subTest(pin=pin):
self.assertFalse(is_valid_pin(pin))
if __name__ == "__main__":
unittest.main(verbosity=2)unittest needs no installation and is used in many older projects. pytest can also run unittest tests, so you can mix both.
A debugging routine that always works
- Reproduce the bug with the smallest input.
- Read the traceback bottom-up.
- Inspect values with logging, breakpoint() or the VS Code debugger.
- Form one guess and test it – change one thing at a time.
- Write a test that fails because of the bug.
- Fix the code until the test passes, then run all tests.
Practice tasks
- Fix
average()above so an empty list returns 0, and write a pytest test for it. - Add logging to a file
app.logwith time stamps (%(asctime)s). - Write 5 pytest tests for a function
is_valid_mobile(number)(10 digits, starts 6–9). - Use
breakpoint()to find why"10" + str(5)gives 105 in a total.
Summary
- Read tracebacks from the bottom: error type → failing line → callers.
- Use logging levels instead of scattered prints.
breakpoint()or the VS Code debugger lets you pause and inspect values.- pytest: files
test_*.py, functionstest_*, plainassert; use parametrize and pytest.raises. - Every fixed bug should get a test so it never returns.
