Testing Basics

Chapter 10: Testing and Debugging

Testing and debugging are essential skills that transform code from "works on my machine" to reliable, maintainable software. Testing verifies code behaves correctly under various conditions, while debugging identifies and fixes problems when things go wrong. Professional developers spend significant time on both, preventing bugs before they reach users and quickly resolving issues when they arise.

For example, before releasing a payment function, you write tests confirming it handles valid payments, rejects invalid ones, and properly processes edge cases. When a bug appears, debugging techniques help you quickly identify the root cause and verify the fix. Mastering these skills elevates your programming from casual scripts to production-quality code.

Understanding Testing

Testing means running code with known inputs and verifying it produces expected outputs. Instead of manually checking every scenario, you write automated tests that verify behavior systematically and repeatedly.

Consider a simple function:

Manual testing means calling it with various inputs and checking results:

This works but doesn't scale. Every code change requires manual re-verification. Automated testing uses assertions to verify results programmatically.

Assert Statements: The Foundation

The assert statement checks if a condition is true, raising an AssertionError if it's false:

def add(a, b):
    return a + b

# Test assertions
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
print("All tests passed!")

If all assertions pass, the program continues. If one fails, Python raises AssertionError with the failing condition. This provides immediate feedback when code breaks.

Add custom messages for clarity:

assert add(10, 5) == 15, "Expected 10 + 5 to equal 15"
assert add(-5, -3) == -8, "Expected -5 + -3 to equal -8"

When assertions fail, these messages explain what went wrong, making debugging faster.

Writing Test Functions

Organize tests into functions with descriptive names starting with test_:

def add(a, b):
    return a + b

def test_add_positive_numbers():
    assert add(2, 3) == 5
    assert add(10, 20) == 30

def test_add_negative_numbers():
    assert add(-1, -1) == -2
    assert add(-5, 3) == -2

def test_add_zero():
    assert add(0, 0) == 0
    assert add(5, 0) == 5
    assert add(0, 5) == 5

# Run tests
test_add_positive_numbers()
test_add_negative_numbers()
test_add_zero()
print("All add() tests passed!")

This structure groups related tests, makes test purposes clear, and simplifies running specific test suites.

The unittest Module

Python's standard library includes unittest for structured testing:

import unittest

def add(a, b):
    return a + b

class TestAddFunction(unittest.TestCase):
    def test_positive_numbers(self):
        self.assertEqual(add(2, 3), 5)
        self.assertEqual(add(10, 20), 30)

    def test_negative_numbers(self):
        self.assertEqual(add(-1, -1), -2)
        self.assertEqual(add(-5, 3), -2)

    def test_with_zero(self):
        self.assertEqual(add(0, 0), 0)
        self.assertEqual(add(5, 0), 5)

# Run tests (in standard Python environment)
# unittest.main() would be called here

The unittest.TestCase class provides assertion methods like assertEqual, assertTrue, assertFalse, and automatic test discovery. While unittest is comprehensive, it's primarily for traditional Python environments. Browser/WASM environments may have limited unittest support.

Debugging with Print Statements

The simplest debugging technique: strategic print statements showing what's happening:

def calculate_discount(price, discount_percent):
    print(f"DEBUG: price={price}, discount_percent={discount_percent}")

    discount_amount = price * (discount_percent / 100)
    print(f"DEBUG: discount_amount={discount_amount}")

    final_price = price - discount_amount
    print(f"DEBUG: final_price={final_price}")

    return final_price

result = calculate_discount(100, 20)
print(f"Result: {result}")

Output reveals the function's logic flow:

DEBUG: price=100, discount_percent=20
DEBUG: discount_amount=20.0
DEBUG: final_price=80.0
Result: 80.0

Print debugging works everywhere (including WASM) and quickly identifies where code deviates from expectations. Remove debug prints before deployment or use logging for production code.

Reading Error Messages and Tracebacks

Python's error messages (tracebacks) provide crucial debugging information:

def divide(a, b):
    return a / b

def calculate(x, y):
    result = divide(x, y)
    return result * 2

print(calculate(10, 0))

This produces:

Traceback (most recent call last):
  File "example.py", line 8, in <module>
    print(calculate(10, 0))
  File "example.py", line 5, in calculate
    result = divide(x, y)
  File "example.py", line 2, in divide
    return a / b
ZeroDivisionError: division by zero

Read tracebacks bottom-to-top:

  1. Error type and message: ZeroDivisionError: division by zero
  2. Where it occurred: divide function, line 2
  3. Call chain: calculate called divide, main code called calculate

Tracebacks show exactly where errors occur and how execution reached that point.

Debugging Patterns

Isolate the Problem:

def complex_calculation(data):
    # Break down into steps
    step1 = data * 2
    print(f"After step 1: {step1}")

    step2 = step1 + 10
    print(f"After step 2: {step2}")

    step3 = step2 / 5
    print(f"After step 3: {step3}")

    return step3

Breaking complex logic into smaller, debuggable steps helps identify exactly where problems occur.

Validate Assumptions:

def process_user(user_data):
    # Validate assumptions
    assert "name" in user_data, "user_data must have 'name' key"
    assert "age" in user_data, "user_data must have 'age' key"
    assert user_data["age"] > 0, "age must be positive"

    # Process with confidence
    return f"{user_data['name']} is {user_data['age']} years old"

Assertions catch invalid data early, before it causes mysterious failures downstream.

Test-Driven Development (TDD) Introduction

Test-Driven Development means writing tests before implementation:

  1. Red: Write a failing test for desired functionality
  2. Green: Write minimal code to pass the test
  3. Refactor: Improve code while keeping tests passing

Example TDD workflow:

# Step 1 (RED): Write failing test
def test_multiply():
    assert multiply(3, 4) == 12
    assert multiply(0, 5) == 0
    assert multiply(-2, 3) == -6

# test_multiply() would fail here since multiply() doesn't exist

# Step 2 (GREEN): Write minimal implementation
def multiply(a, b):
    return a * b

# Now test_multiply() passes

# Step 3 (REFACTOR): Improve if needed
def multiply(a, b):
    """Multiply two numbers and return the result."""
    return a * b

TDD ensures tests exist for all functionality, catches regressions early, and guides design toward testable code.

Practical Testing Strategies

Edge Cases:

def get_grade(score):
    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_get_grade():
    # Test boundaries
    assert get_grade(90) == "A"  # Boundary
    assert get_grade(89) == "B"  # Just below
    assert get_grade(100) == "A" # Maximum

    # Test middle ranges
    assert get_grade(85) == "B"
    assert get_grade(75) == "C"

    # Test edge cases
    assert get_grade(0) == "F"   # Minimum
    assert get_grade(60) == "D"  # Boundary

test_get_grade()
print("Grade tests passed!")

Testing boundaries and edge cases catches off-by-one errors and boundary conditions.

Testing Error Handling:

def safe_divide(a, b):
    if b == 0:
        return None
    return a / b

def test_safe_divide():
    # Normal cases
    assert safe_divide(10, 2) == 5
    assert safe_divide(9, 3) == 3

    # Error handling
    assert safe_divide(10, 0) is None  # Division by zero
    assert safe_divide(0, 5) == 0      # Zero numerator is valid

test_safe_divide()
print("Safe divide tests passed!")

Test both success paths and error conditions to ensure robust error handling.

WASM and Browser Testing Considerations

Browser/WASM Python environments have testing limitations:

Works in WASM:

  • Basic assert statements
  • Custom test functions
  • Print debugging
  • JSON-based test results

Limited in WASM:

  • Full unittest module (limited stdlib)
  • File-based test discovery
  • Some third-party testing frameworks

For browser environments, focus on assert-based tests and custom test runners. Traditional Python development uses full testing frameworks like unittest or pytest.

Try It Yourself: Practice Exercises

Exercise 1: Test a Function
Write a function is_even(n) that returns True if n is even. Write 4 assertions testing positive, negative, zero, and large numbers.

Exercise 2: Debug with Prints
The function below has a bug. Add print statements to identify and fix it:

def average(numbers):
    total = sum(numbers)
    return total / len(numbers)
# Bug: crashes on empty list

Exercise 3: Test Edge Cases
Write a function clamp(value, min_val, max_val) that restricts value between min and max. Write tests for values below, within, and above range.

Exercise 4: Error Message
Write assertions for a validate_email(email) function with custom error messages explaining what's wrong.

Exercise 5: TDD Practice
Write tests for a reverse_string(s) function first, then implement it to pass tests.

Exercise 6: Test Error Handling
Write a get_item(list, index) function that returns None for invalid indices. Write tests for valid indices, negative indices, and out-of-range indices.

What's Next?

You've mastered Python testing and debugging fundamentals: using assert statements, writing test functions, debugging with prints and tracebacks, understanding TDD principles, and testing edge cases. These skills build reliable, maintainable code and help you identify and fix issues quickly.

In the next chapter, we'll explore Python's ecosystem and community—discovering packages with pip, exploring PyPI, understanding virtual environments, and leveraging the vast Python ecosystem to build powerful applications without reinventing the wheel.

Continue to Chapter 11: Python Ecosystem and Best Practices

📝 Test Your Knowledge: Testing Basics

Take this quiz to reinforce what you've learned in this chapter.