41. Debugging and Testing Your Code
Writing code is only half the battle. The other half is making sure your code works correctly and finding problems when it doesn't. Every programmer, from beginners to experts, writes code with bugs. The difference is that experienced programmers have developed systematic approaches to finding and fixing those bugs.
In this chapter, you'll learn practical debugging techniques that help you understand what your code is actually doing, locate problems quickly, and verify that your code works as intended. These skills will make you a more confident and effective programmer.
41.1) Reading Tracebacks to Locate Errors (Quick Review)
As we learned in Chapter 24, Python provides detailed error messages called tracebacks when something goes wrong. Let's review how to read them effectively, since this is your first line of defense when debugging.
41.1.1) The Anatomy of a Traceback
When Python encounters an error, it shows you exactly where the problem occurred and what type of error it was. Here's a typical traceback:
def calculate_average(numbers):
total = sum(numbers)
count = len(numbers)
return total / count
def process_student_grades(grades):
average = calculate_average(grades)
return f"Average: {average:.1f}"
# This will cause an error
student_grades = []
result = process_student_grades(student_grades)
print(result)Output:
Traceback (most recent call last):
File "grades.py", line 12, in <module>
result = process_student_grades(student_grades)
File "grades.py", line 7, in process_student_grades
average = calculate_average(grades)
File "grades.py", line 4, in calculate_average
return total / count
~~~~~~^~~~~~~
ZeroDivisionError: division by zeroLet's break down what this traceback tells us:
Reading from bottom to top:
- The error type and message (bottom):
ZeroDivisionError: division by zerotells us exactly what went wrong - The exact line where the error occurred:
return total / countin line 4 - The call chain showing how we got there: started at line 12, went through line 7, ended at line 4
41.1.2) Using Tracebacks to Find the Root Cause
The traceback shows you the symptom (where the error occurred), but you need to find the cause (why it occurred). Let's trace through the problem:
# The error happens here
return total / count # count is 0
# But the real problem is here
student_grades = [] # Empty list passed to the functionThe division by zero happens because we passed an empty list. The traceback points to line 4, but the fix needs to happen earlier—either by validating the input or handling the empty list case:
def calculate_average(numbers):
"""Return the average of numbers, or None if the list is empty."""
if not numbers:
return None
return sum(numbers) / len(numbers)
def process_student_grades(grades):
"""Process student grades and return a formatted string."""
average = calculate_average(grades)
if average is None:
return "No grades to process"
return f"Average: {average:.1f}"
# Now this works safely
student_grades = []
result = process_student_grades(student_grades)
print(result) # Output: No grades to process
# And this works too
student_grades = [85, 92, 78, 90]
result = process_student_grades(student_grades)
print(result) # Output: Average: 86.2Key Takeaways:
- Read tracebacks from bottom to top
- The error location (symptom) isn't always the root cause
- Validate inputs early to prevent errors later
- Use defensive programming (
.get(), length checks) for safer code
Different types of errors produce different tracebacks, but the reading process is always the same: start at the bottom to see what went wrong, then trace upward to understand how you got there. If you need a refresher on specific exception types, refer back to Chapter 24.
Now that you can read tracebacks effectively, let's learn how to trace through your code mentally to understand what it's doing step by step.
41.2) Tracing Code Execution Mentally
Sometimes you encounter a bug but can't immediately run the code—maybe you're reviewing code on paper, reading someone else's pull request, or trying to understand why a function behaves unexpectedly. In these situations, mental execution—stepping through code line by line in your head, tracking what happens to each variable—becomes invaluable.
Even experienced programmers use this technique regularly. Before adding print statements or running a debugger, they often trace through a few iterations mentally to form a hypothesis about where the problem might be. This is faster than trial-and-error and helps you understand your code more deeply.
Mental execution is particularly useful when:
- Reading unfamiliar code to understand what it does
- Reviewing small functions (5-15 lines) before running them
- Debugging logic errors where the code runs but produces wrong results
- Understanding loop behavior when the pattern isn't immediately obvious
- Code review where you can't easily run the code yourself
For larger or more complex code, you'll combine mental tracing with other techniques we'll cover later in this chapter. But mastering this skill will make you a much more effective debugger.
41.2.1) The Mental Execution Process
When you mentally execute code, you act as the Python interpreter, following the same rules Python follows. Let's practice with a simple example:
def find_maximum(numbers):
max_value = numbers[0]
for num in numbers:
if num > max_value:
max_value = num
return max_value
result = find_maximum([3, 7, 2, 9, 5])
print(result) # Output: 9Here's how to trace through this code:
Step-by-step trace:
Initial state:
numbers = [3, 7, 2, 9, 5]
max_value = 3 (numbers[0])
Iteration 1: num = 3
Check: 3 > 3? → False
max_value remains 3
Iteration 2: num = 7
Check: 7 > 3? → True
max_value = 7 ✓
Iteration 3: num = 2
Check: 2 > 7? → False
max_value remains 7
Iteration 4: num = 9
Check: 9 > 7? → True
max_value = 9 ✓
Iteration 5: num = 5
Check: 5 > 9? → False
max_value remains 9
Return: 941.2.2) Creating a Trace Table
For more complex code, create a trace table that shows how variables change over time. This is especially helpful for loops and nested structures:
def calculate_running_totals(numbers):
totals = []
running_sum = 0
for num in numbers:
running_sum += num
totals.append(running_sum)
return totals
result = calculate_running_totals([10, 20, 30, 40])
print(result) # Output: [10, 30, 60, 100]Trace table:
The table shows the state of variables at each step. Notice how running_sum changes from "before" to "after" each addition:
| Iteration | num | running_sum (before) | running_sum (after) | totals |
|---|---|---|---|---|
| Start | - | 0 | 0 | [] |
| 1 | 10 | 0 | 10 | [10] |
| 2 | 20 | 10 | 30 | [10, 30] |
| 3 | 30 | 30 | 60 | [10, 30, 60] |
| 4 | 40 | 60 | 100 | [10, 30, 60, 100] |
Creating this table helps you see exactly how the data flows through your code. If the output doesn't match what you expect, you can pinpoint exactly where things go wrong.
41.2.3) Tracing Through Conditional Logic
Conditional statements require careful attention to which branches execute. Let's trace through a more complex example:
def categorize_grade(score):
if score >= 90:
category = "Excellent"
bonus = 10
elif score >= 80:
category = "Good"
bonus = 5
elif score >= 70:
category = "Satisfactory"
bonus = 0
else:
category = "Needs Improvement"
bonus = 0
final_score = score + bonus
return category, final_score
result = categorize_grade(85)
print(result) # Output: ('Good', 90)Mental trace for score = 85:
- Check
85 >= 90→ False, skip first block - Check
85 >= 80→ True, enter second block - Set
category = "Good"andbonus = 5 - Skip remaining elif and else blocks (already found a match)
- Calculate
final_score = 85 + 5 = 90 - Return
("Good", 90)
41.2.4) Tracing Function Calls and Returns
When functions call other functions, you need to track the call stack—the sequence of function calls and their local variables:
def calculate_tax(amount, rate):
tax = amount * rate
return tax
def calculate_total(price, quantity, tax_rate):
subtotal = price * quantity
tax = calculate_tax(subtotal, tax_rate)
total = subtotal + tax
return total
result = calculate_total(50, 3, 0.08)
print(f"Total: ${result:.2f}") # Output: Total: $162.00Trace with call stack:
┌─ calculate_total(50, 3, 0.08)
│ price = 50, quantity = 3, tax_rate = 0.08
│ subtotal = 150
│
│ ┌─ calculate_tax(150, 0.08)
│ │ amount = 150, rate = 0.08
│ │ tax = 12.0
│ │ return 12.0
│ └─
│
│ tax = 12.0 (from calculate_tax)
│ total = 162.0
│ return 162.0
└─
result = 162.0This step-by-step trace shows exactly how data flows between functions. When debugging, if the final result is wrong, you can trace back to see which function produced an incorrect intermediate value.
Mental tracing is powerful, but for complex code it can be tedious. In the next section, we'll learn how to use print statements strategically to see what's actually happening as your code runs, which is often faster and more reliable than mental execution alone.
41.3) Debugging with Print: f"{var=}" and repr()
While mental execution works well for small functions, it becomes impractical for larger or more complex code. When you're not sure what's happening inside a loop, or when a calculation produces unexpected results, the fastest way to investigate is often to add strategic print() statements.
Print debugging has some advantages over other techniques:
- No special tools needed: Works in any Python environment
- Quick to implement: Add a print statement in seconds
- Clear output: You see exactly what you asked for
- Easy to remove: Delete the prints when done
Professional developers use print debugging all the time—it's not a "beginner" technique. Let's learn how to use it effectively.
41.3.1) Basic Print Debugging
The simplest debugging approach is to print variable values at key points in your code:
def process_order(items, discount_rate):
print(f"Starting process_order")
print(f"Items: {items}")
print(f"Discount rate: {discount_rate}")
subtotal = sum(item['price'] * item['quantity'] for item in items)
print(f"Subtotal: {subtotal}")
discount = subtotal * discount_rate
print(f"Discount amount: {discount}")
total = subtotal - discount
print(f"Final total: {total}")
return total
order_items = [
{'name': 'Book', 'price': 25.99, 'quantity': 2},
{'name': 'Pen', 'price': 3.50, 'quantity': 5}
]
result = process_order(order_items, 0.10)Output:
Starting process_order
Items: [{'name': 'Book', 'price': 25.99, 'quantity': 2}, {'name': 'Pen', 'price': 3.5, 'quantity': 5}]
Discount rate: 0.1
Subtotal: 69.47999999999999
Discount amount: 6.9479999999999995
Final total: 62.53199999999999These print statements show you the flow of execution and the values at each step. If the final result is wrong, you can see exactly where the calculation went off track.
41.3.2) Using f"{var=}" for Quick Inspection
Python 3.8 introduced a convenient debugging syntax: f"{var=}". This prints both the variable name and its value:
def calculate_compound_interest(principal, rate, years):
# Traditional approach
print(f"principal: {principal}")
print(f"rate: {rate}")
print(f"years: {years}")
# Cleaner approach with f"{var=}"
print(f"{principal=}")
print(f"{rate=}")
print(f"{years=}")
# You can use expressions, not just variables
print(f"{principal * rate=}")
print(f"{(1 + rate) ** years=}")
amount = principal * (1 + rate) ** years
print(f"{amount=}")
return amount
result = calculate_compound_interest(1000, 0.05, 10)Output:
principal: 1000
rate: 0.05
years: 10
principal=1000
rate=0.05
years=10
principal * rate=50.0
(1 + rate) ** years=1.628894626777442
amount=1628.89462677744241.3.3) Using repr() to See the True Form of Data
Sometimes what you see printed isn't what you think it is. The repr() function shows you the exact representation of an object, including hidden characters:
# These strings look the same when printed
text1 = "Hello"
text2 = "Hello\n" # Has a newline at the end
print("Using print():")
print(f"text1: {text1}")
print(f"text2: {text2}")
print("\nUsing repr():")
print(f"text1: {repr(text1)}")
print(f"text2: {repr(text2)}")Output:
Using print():
text1: Hello
text2: Hello
Using repr():
text1: 'Hello'
text2: 'Hello\n'The repr() output shows that text2 has a hidden newline character. This is crucial when debugging string processing:
def clean_user_input():
# User input often has hidden whitespace
username = input("Enter username: ") # User types "Alice "
print(f"Username with print(): {username}")
print(f"Username with repr(): {repr(username)}")
# Clean the input
cleaned = username.strip()
print(f"Cleaned with repr(): {repr(cleaned)}")
return cleanedIf a user types "Alice" followed by spaces and hits Enter, you might see:
Output:
Enter username: Alice
Username with print(): Alice
Username with repr(): 'Alice '
Cleaned with repr(): 'Alice'The repr() output reveals the trailing spaces that print() doesn't show clearly.
When to use repr() vs str():
repr() is designed for developers—it shows the "official" string representation that could recreate the object. str() (which print() uses by default) is designed for end users—it shows a readable, friendly version.
For debugging, repr() is usually more helpful because it reveals the true structure of your data.
41.3.4) Strategic Print Placement
Don't just scatter print statements everywhere. Place them strategically:
def calculate_shipping_cost(weight, distance, express=False):
print(f"=== calculate_shipping_cost called ===")
print(f"Input: {weight=}, {distance=}, {express=}")
# Calculate base cost
base_rate = 0.50
base_cost = weight * distance * base_rate
print(f"Calculated: {base_cost=}")
# Apply express surcharge
if express:
surcharge = base_cost * 0.50
print(f"Express surcharge: {surcharge=}")
total = base_cost + surcharge
else:
print("No express surcharge")
total = base_cost
print(f"Final: {total=}")
print(f"=== calculate_shipping_cost returning ===\n")
return total
# Test different scenarios
cost1 = calculate_shipping_cost(10, 500, express=True)
cost2 = calculate_shipping_cost(5, 200, express=False)Output:
=== calculate_shipping_cost called ===
Input: weight=10, distance=500, express=True
Calculated: base_cost=2500.0
Express surcharge: surcharge=1250.0
Final: total=3750.0
=== calculate_shipping_cost returning ===
=== calculate_shipping_cost called ===
Input: weight=5, distance=200, express=False
Calculated: base_cost=500.0
No express surcharge
Final: total=500.0
=== calculate_shipping_cost returning ===The clear markers (===) and organized output make it easy to follow the execution flow.
41.3.5) Removing Debug Prints
Once you've found and fixed the bug, remember to remove your debug prints. Here are some strategies:
Strategy 1: Use a distinct prefix
# Easy to find and remove with search/replace
print(f"DEBUG: {total=}")
print(f"DEBUG: {items=}")Strategy 2: Use a debug flag
DEBUG = True
def calculate_total(items):
if DEBUG:
print(f"Processing {len(items)} items")
total = sum(item['price'] for item in items)
if DEBUG:
print(f"{total=}")
return total
# Turn off all debug output at once
DEBUG = FalseStrategy 3: Comment them out but keep them
def process_data(data):
# print(f"DEBUG: {data=}") # Useful for future debugging
result = transform(data)
# print(f"DEBUG: {result=}")
return resultFor more sophisticated logging that you can leave in production code, Python has a logging module, but simple print statements are perfect for quick debugging during development.
Print debugging shows you the values of variables, but sometimes you need to understand the structure of an object—what methods it has, what type it is, and what it can do. In the next section, we'll learn how to inspect objects using type() and dir().
41.4) Inspecting Objects: type() and dir()
Print debugging shows you the values of your variables, but sometimes the problem isn't the value—it's the type of object you're working with. You might expect a list but receive a string, or you're working with an unfamiliar object and don't know what methods it supports.
Python provides built-in tools to inspect objects: type() tells you what kind of object you have, and dir() shows you what operations it supports. These functions are essential when:
- Debugging type-related errors (TypeError, AttributeError)
- Working with unfamiliar libraries or APIs
- Understanding objects returned by third-party code
- Verifying that your code receives the expected types
Let's learn how to use these inspection tools effectively.
41.4.1) Using type() to Identify Object Types
The type() function tells you exactly what kind of object you have. This is crucial when debugging type-related errors:
def process_data(data):
print(f"Received data: {data}")
print(f"Data type: {type(data)}")
if isinstance(data, list):
print("Processing as list")
return sum(data)
elif isinstance(data, dict):
print("Processing as dictionary")
return sum(data.values())
else:
print("Unexpected type!")
return None
# Test with different types
result1 = process_data([10, 20, 30])
print(f"Result: {result1}\n")
result2 = process_data({'a': 10, 'b': 20, 'c': 30})
print(f"Result: {result2}\n")
result3 = process_data("123")
print(f"Result: {result3}")Output:
Received data: [10, 20, 30]
Data type: <class 'list'>
Processing as list
Result: 60
Received data: {'a': 10, 'b': 20, 'c': 30}
Data type: <class 'dict'>
Processing as dictionary
Result: 60
Received data: 123
Data type: <class 'str'>
Unexpected type!
Result: None41.4.2) Debugging Type Confusion
Type confusion is a common source of bugs, especially when working with functions that can receive data from multiple sources—user input, file reading, API responses, or other functions. You might expect a list of numbers but accidentally receive a string, or expect a dictionary but get a list.
Using type() helps identify when you have the wrong type. By printing the type early in your function, you can immediately spot type mismatches before they cause confusing error messages deeper in your code:
def calculate_average(numbers):
print(f"{type(numbers)=}")
print(f"{numbers=}") # Show what we actually got
# This will fail if numbers is not a list of numbers
total = sum(numbers)
count = len(numbers)
return total / count
# Common mistake: forgot to convert string to list
scores = "85" # Should be [85] or just 85
try:
avg = calculate_average(scores)
print(f"Average: {avg}")
except TypeError as e:
print(f"TypeError: {e}")
print(f"Expected list of numbers, got {type(scores)}")
print(f"The string contains: {repr(scores)}")Output:
type(numbers)=<class 'str'>
numbers='85'
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Expected list of numbers, got <class 'str'>
The string contains: '85'The type() check immediately reveals the problem: we passed a string when we needed a list. Without this debug output, you might have spent time trying to understand why sum() failed, when the real issue is that the wrong type of data entered the function in the first place.
41.4.3) Using dir() to Discover Available Methods
When working with unfamiliar objects—whether from a library you're learning, an API response, or even Python's built-in types—you often need to know: "What can I do with this object?" The dir() function answers this question by listing all attributes and methods available on an object.
This is particularly valuable when:
- You're exploring a new library and want to see what methods an object provides
- You receive an object from third-party code and need to understand its capabilities
- You've forgotten the exact name of a method you want to use
- You're debugging and want to verify that an object has the methods you expect
Let's explore what methods a string has:
# Exploring what methods a string has
text = "Python Programming"
print(f"Type: {type(text)}")
print(f"\nAvailable string methods (showing first 10):")
methods = [m for m in dir(text) if not m.startswith('_')]
for method in methods[:10]: # Show first 10
print(f" {method}")
print(f" ... and {len(methods) - 10} more")Output:
Type: <class 'str'>
Available string methods (showing first 10):
capitalize
casefold
center
count
encode
endswith
expandtabs
find
format
format_map
... and 37 moreNow you can see all the operations available on strings. If you weren't sure whether strings had a count method or an endswith method, dir() shows you they exist. You can then use Python's help() function to learn more about any specific method:
# Learn more about a specific method
help(text.count)This will show you the documentation for the count method:
Help on built-in function count:
count(sub[, start[, end]], /) method of builtins.str instance
Return the number of non-overlapping occurrences of substring sub in string S[start:end].
Optional arguments start and end are interpreted as in slice notation.The dir() function is like having documentation built right into Python—it shows you what's possible with any object you're working with.
41.4.4) Inspecting Custom Objects
When working with custom classes, type() and dir() help you understand what you're dealing with. Additionally, Python provides hasattr() to check if an object has a specific attribute before trying to access it—this prevents AttributeError exceptions.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def get_status(self):
return "Passing" if self.grade >= 60 else "Failing"
student = Student("Alice", 85)
print(f"Object type: {type(student)}")
print(f"\nAvailable attributes and methods:")
for attr in dir(student):
if not attr.startswith('_'):
print(f" {attr}")
# Check if specific attributes exist
print(f"\nHas 'name' attribute: {hasattr(student, 'name')}")
print(f"Has 'age' attribute: {hasattr(student, 'age')}")
print(f"Has 'get_status' method: {hasattr(student, 'get_status')}")
# Now we can safely access attributes we know exist
if hasattr(student, 'name'):
print(f"\nStudent name: {student.name}")
else:
print("\nNo name attribute found")
if hasattr(student, 'get_status'):
print(f"Status: {student.get_status()}")
else:
print("No get_status method found")
# This prevents errors like this:
# print(student.age) # Would raise AttributeError!Output:
Object type: <class '__main__.Student'>
Available attributes and methods:
get_status
grade
name
Has 'name' attribute: True
Has 'age' attribute: False
Has 'get_status' method: True
Student name: Alice
Status: PassingThe hasattr() function is essential for writing defensive code—code that checks whether operations are safe before performing them. The function returns True if the attribute exists, False if it doesn't—allowing you to make decisions before attempting to access attributes. This is especially important when working with objects from external libraries or user input where you can't guarantee what attributes will be present.
41.4.5) Using getattr() for Safe Attribute Access
When you're not sure if an attribute exists, use getattr() with a default value:
def display_student_info(student):
"""Safely display student info even if some attributes are missing."""
print(f"Type: {type(student)}")
# Safe attribute access with defaults
name = getattr(student, 'name', 'Unknown')
grade = getattr(student, 'grade', 0)
age = getattr(student, 'age', 'Not specified')
print(f"Name: {name}")
print(f"Grade: {grade}")
print(f"Age: {age}")
# Check if method exists before calling
if hasattr(student, 'get_status'):
status = student.get_status()
print(f"Status: {status}")
# Using the same Student class from above
student = Student("Bob", 72)
display_student_info(student)Output:
Type: <class '__main__.Student'>
Name: Bob
Grade: 72
Age: Not specified
Status: PassingThis approach prevents AttributeError exceptions when working with objects that might not have all expected attributes. The getattr() function is especially useful when:
- Working with objects from external APIs that might have different versions
- Handling optional attributes in your own classes
- Building defensive code that gracefully handles missing data
Understanding what type of object you have and what methods it supports is crucial for debugging. But sometimes you need to verify not just that your code runs, but that it produces the correct results. In the next section, we'll learn how to use assert statements to test your assumptions and catch bugs early.
41.5) Testing with assert Statements
We've learned how to debug code when things go wrong—reading tracebacks, tracing execution mentally, using print statements, and inspecting objects. But there's a better approach than fixing bugs after they appear: preventing them in the first place through testing.
The assert statement is Python's simplest testing tool. It lets you verify that your code behaves correctly by checking assumptions at critical points. When an assertion fails, Python immediately tells you exactly what went wrong and where, making it much easier to catch bugs early—often before you even run your main program.
Assertions are particularly valuable for:
- Verifying that functions produce expected results
- Checking that inputs meet your requirements
- Testing edge cases that might break your code
- Documenting assumptions your code relies on
Think of assertions as automated checks that continuously verify your code is working as intended. Let's learn how to use them effectively.
41.5.1) What assert Does
An assert statement checks if a condition is true. If the condition is true, nothing happens—the code continues normally. If it's false, Python raises an AssertionError and stops execution.
Syntax:
assert condition, "Optional error message"condition: Any expression that evaluates to True or False"Optional error message": Helpful text shown when the assertion fails
Here's how it works in practice:
# Simple assertions
x = 10
assert x > 0 # Passes silently (x is indeed > 0)
assert x < 5 # Fails! Raises AssertionError
# With error messages (much more helpful!)
assert x > 0, f"x must be positive, got {x}"
assert x < 5, f"x must be less than 5, got {x}" # Fails with clear messageNow let's see assertions in a real function:
def calculate_discount(price, discount_percent):
# Verify inputs are valid
assert price >= 0, "Price cannot be negative"
assert 0 <= discount_percent <= 100, "Discount must be between 0 and 100"
discount_amount = price * (discount_percent / 100)
final_price = price - discount_amount
# Verify output makes sense
assert final_price >= 0, "Final price cannot be negative"
return final_price
# Valid inputs work fine
result = calculate_discount(100, 20)
print(f"Price after 20% discount: ${result}") # Output: Price after 20% discount: $80.0
# Invalid inputs trigger assertions
try:
result = calculate_discount(-50, 20)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Price cannot be negative
try:
result = calculate_discount(100, 150)
except AssertionError as e:
print(f"Assertion failed: {e}") # Output: Assertion failed: Discount must be between 0 and 10041.5.2) Using Assertions to Verify Function Behavior
Assertions are excellent for testing that functions produce expected results:
def calculate_average(numbers):
if not numbers:
return 0.0
return sum(numbers) / len(numbers)
# Test with various inputs
result = calculate_average([10, 20, 30])
assert result == 20.0, f"Expected 20.0, got {result}"
print(f"Test 1 passed: average of [10, 20, 30] = {result}")
result = calculate_average([5, 5, 5, 5])
assert result == 5.0, f"Expected 5.0, got {result}"
print(f"Test 2 passed: average of [5, 5, 5, 5] = {result}")
result = calculate_average([])
assert result == 0.0, f"Expected 0.0 for empty list, got {result}"
print(f"Test 3 passed: average of [] = {result}")
result = calculate_average([100])
assert result == 100.0, f"Expected 100.0, got {result}"
print(f"Test 4 passed: average of [100] = {result}")Output:
Test 1 passed: average of [10, 20, 30] = 20.0
Test 2 passed: average of [5, 5, 5, 5] = 5.0
Test 3 passed: average of [] = 0.0
Test 4 passed: average of [100] = 100.0If any assertion fails, you immediately know which test case revealed the problem.
41.5.3) Testing Edge Cases
Edge cases are inputs at the boundaries of what your function should handle. Testing these reveals bugs that normal inputs might miss:
def get_first_and_last(items):
"""Return the first and last items from a sequence."""
assert len(items) > 0, "Cannot get first and last from empty sequence"
return items[0], items[-1]
# Test normal case
result = get_first_and_last([1, 2, 3, 4, 5])
assert result == (1, 5), f"Expected (1, 5), got {result}"
print(f"Normal case: {result}")
# Test edge case: single item
result = get_first_and_last([42])
assert result == (42, 42), f"Expected (42, 42), got {result}"
print(f"Single item: {result}")
# Test edge case: two items
result = get_first_and_last([10, 20])
assert result == (10, 20), f"Expected (10, 20), got {result}"
print(f"Two items: {result}")
# Test edge case: empty sequence (should fail)
try:
result = get_first_and_last([])
print("ERROR: Should have raised AssertionError for empty list")
except AssertionError as e:
print(f"Empty list correctly rejected: {e}")Output:
Normal case: (1, 5)
Single item: (42, 42)
Two items: (10, 20)
Empty list correctly rejected: Cannot get first and last from empty sequence41.5.4) Testing Data Transformations
When your function transforms data, assert that the transformation is correct:
def remove_duplicates(items):
"""Remove duplicates while preserving order."""
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
# Test basic duplicate removal
input_data = [1, 2, 2, 3, 1, 4, 3, 5]
result = remove_duplicates(input_data)
expected = [1, 2, 3, 4, 5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 1 passed: {input_data} -> {result}")
# Test that order is preserved
input_data = [3, 1, 2, 1, 3, 2]
result = remove_duplicates(input_data)
expected = [3, 1, 2]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 2 passed: {input_data} -> {result}")
# Test with no duplicates
input_data = [1, 2, 3, 4, 5]
result = remove_duplicates(input_data)
assert result == input_data, f"Expected {input_data}, got {result}"
print(f"Test 3 passed: {input_data} -> {result}")
# Test with all duplicates
input_data = [5, 5, 5, 5]
result = remove_duplicates(input_data)
expected = [5]
assert result == expected, f"Expected {expected}, got {result}"
print(f"Test 4 passed: {input_data} -> {result}")Output:
Test 1 passed: [1, 2, 2, 3, 1, 4, 3, 5] -> [1, 2, 3, 4, 5]
Test 2 passed: [3, 1, 2, 1, 3, 2] -> [3, 1, 2]
Test 3 passed: [1, 2, 3, 4, 5] -> [1, 2, 3, 4, 5]
Test 4 passed: [5, 5, 5, 5] -> [5]41.5.5) Creating a Simple Test Function
As your code grows, scattering assert statements throughout your main code becomes messy and hard to manage. A better approach is to organize your tests into dedicated test functions. This separates testing code from production code and makes it easy to run all your tests at once.
Why use dedicated test functions?
- Organization: All tests for a function are in one place
- Reusability: Run tests anytime you change the code
- Documentation: Tests show how the function should behave
- Debugging: When a test fails, you immediately know which scenario broke
- Development workflow: Test first, then implement or fix the code
Let's see this in practice:
def calculate_grade(score):
"""Convert numeric score to letter grade."""
if score >= 90:
return 'A'
elif score >= 80:
return 'B'
elif score >= 70:
return 'C'
elif score >= 60:
return 'D'
else:
return 'F'
def test_calculate_grade():
"""Test the calculate_grade function.
This function tests all expected behaviors:
- Each grade range (A, B, C, D, F)
- Boundary values (90, 80, 70, 60)
- Edge cases (just below each boundary)
"""
print("Testing calculate_grade...")
# Test A grades
assert calculate_grade(95) == 'A', "95 should be A"
assert calculate_grade(90) == 'A', "90 should be A (boundary)"
print(" ✓ A grades: passed")
# Test B grades
assert calculate_grade(85) == 'B', "85 should be B"
assert calculate_grade(80) == 'B', "80 should be B (boundary)"
print(" ✓ B grades: passed")
# Test C grades
assert calculate_grade(75) == 'C', "75 should be C"
assert calculate_grade(70) == 'C', "70 should be C (boundary)"
print(" ✓ C grades: passed")
# Test D grades
assert calculate_grade(65) == 'D', "65 should be D"
assert calculate_grade(60) == 'D', "60 should be D (boundary)"
print(" ✓ D grades: passed")
# Test F grades
assert calculate_grade(55) == 'F', "55 should be F"
assert calculate_grade(0) == 'F', "0 should be F"
print(" ✓ F grades: passed")
# Test boundary edge cases (one below each threshold)
assert calculate_grade(89) == 'B', "89 should be B (just below A)"
assert calculate_grade(79) == 'C', "79 should be C (just below B)"
assert calculate_grade(69) == 'D', "69 should be D (just below C)"
assert calculate_grade(59) == 'F', "59 should be F (just below D)"
print(" ✓ Boundary cases: passed")
print("All tests passed! ✓\n")
# Run the tests
test_calculate_grade()
# Now you can confidently use the function
student_score = 87
grade = calculate_grade(student_score)
print(f"Student score {student_score} = Grade {grade}")Output:
Testing calculate_grade...
✓ A grades: passed
✓ B grades: passed
✓ C grades: passed
✓ D grades: passed
✓ F grades: passed
✓ Boundary cases: passed
All tests passed! ✓
Student score 87 = Grade BBenefits of this approach:
- Clear test organization: You can see all test cases at a glance
- Easy to run: Just call
test_calculate_grade()whenever you modify the function - Progressive feedback: See which test groups pass as the function runs
- Self-documenting: The test function shows exactly how
calculate_grade()should work
When to run your tests:
- Before making changes: Ensure your tests pass with the current code
- After making changes: Verify you didn't break anything
- When adding features: Write tests for the new feature first (test-driven development)
- When fixing bugs: Add a test that reproduces the bug, then fix it
This simple pattern—writing test functions with assertions—is the foundation of professional software testing. As you advance, you'll learn about testing frameworks like pytest and unittest, but the core idea remains the same: write functions that verify your code works correctly.
41.5.6) When to Use Assertions vs Exceptions
Understanding when to use assertions versus exceptions is crucial. They serve fundamentally different purposes:
Assertions are for finding bugs during development:
- They check things that should never be false if your code is written correctly
- They verify your own code's internal assumptions and logic
- They help you catch programming mistakes while you're writing and testing code
- Example: "At this point in my function, this list should never be empty"
- Example: "All items in this list should be integers because I just filtered them"
Exceptions are for handling errors that can happen during normal operation:
- They deal with external conditions you cannot control
- They handle situations that might occur even when your code is perfect
- They allow your program to recover gracefully or fail informatively
- Example: User enters text when you expected a number
- Example: A file your code tries to open doesn't exist
- Example: Network request times out
The key difference: Assertions say "this should be impossible," while exceptions say "this might happen, and here's how we'll handle it."
Let's see this in practice:
# Example 1: Function used with USER INPUT
# Users might enter anything, including 0
def calculate_user_ratio(numerator, denominator):
"""Calculate ratio from user-provided numbers."""
# User might enter 0, so use exception handling
if denominator == 0:
raise ValueError("Denominator cannot be zero")
return numerator / denominator
# Example 2: Internal calculation where 0 should be impossible
def calculate_percentage(part, total):
"""Calculate what percentage 'part' is of 'total'."""
# This is called internally after we've verified total > 0
# If total is 0, it's a programming bug in our code
assert total > 0, "total must be positive - check calling code"
return (part / total) * 100More examples of what each should handle:
| Situation | Use Assertion | Use Exception |
|---|---|---|
| User enters invalid input | ❌ No | ✅ Yes |
| File doesn't exist | ❌ No | ✅ Yes |
| Network request fails | ❌ No | ✅ Yes |
| Function gets wrong parameter type from your code | ✅ Yes | ❌ No |
| List should have items but is empty due to logic error | ✅ Yes | ❌ No |
| Data structure in unexpected state due to bug | ✅ Yes | ❌ No |
| Database connection fails | ❌ No | ✅ Yes |
| API returns unexpected format | ❌ No | ✅ Yes |
| Your algorithm produces mathematically impossible result | ✅ Yes | ❌ No |
Critical limitation of assertions:
Assertions can be completely disabled when Python runs with optimization:
python -O script.py # All assert statements are ignored!When assertions are disabled, they simply disappear—Python doesn't check them at all. This means:
- ❌ Never use assertions for validating user input
- ❌ Never use assertions for security checks
- ❌ Never use assertions for anything that must always work in production
# DANGEROUS - DON'T DO THIS:
def process_payment(amount):
assert amount > 0, "Amount must be positive" # WRONG! Gets disabled with -O
# Process payment...
# CORRECT - DO THIS:
def process_payment(amount):
if amount <= 0:
raise ValueError("Amount must be positive") # Always checked!
# Process payment...In summary:
-
Assertions = "I'm checking my own code for bugs during development"
- Think: "This should be impossible if I coded correctly"
- They help you find mistakes in your logic
-
Exceptions = "I'm handling real-world conditions that can actually occur"
- Think: "This might happen during normal use, and I need to deal with it"
- They help your program handle unpredictable situations
Assertions are development and debugging tools that help you write correct code. Exceptions are production tools that help your program handle the messy reality of user input, file systems, networks, and other external factors you cannot control.
You've now learned the essential debugging and testing techniques that will serve you throughout your programming journey:
- Reading tracebacks to quickly locate where errors occur
- Tracing code mentally to understand what your code does step by step
- Using print statements strategically to see runtime values and flow
- Inspecting objects with
type()anddir()to understand what you're working with - Testing with assertions to verify your code works and catch bugs early
These skills work together as a complete debugging toolkit. When you encounter a problem:
- Read the traceback to find where it failed
- Use print debugging or mental tracing to understand why
- Use type/dir inspection when you're unsure what an object can do
- Write assertions to prevent the bug from returning
With practice, you'll develop an intuition for which technique to use in each situation. Remember: every programmer debugs code—the difference is that experienced programmers do it systematically and efficiently. These techniques will make you one of them.