Testing & Debugging

Build confidence in your code through systematic testing and effective debugging

Why Testing Matters

Professional software is not defined by how rarely it breaks, but by how confidently it can be changed. Testing provides fast feedback, prevents regressions, and allows teams to evolve code safely. A well-tested codebase becomes easier to maintain and extend over time.

The ROI of Testing:

  • Catch bugs early when they're cheaper to fix
  • Document expected behavior through examples
  • Enable fearless refactoring and optimization
  • Reduce debugging time by 60-80%

The Testing Pyramid

Unit TestsIntegrationE2E
Unit Tests (70-80%)

Fast, isolated, test individual functions. Run in milliseconds.

Integration Tests (15-20%)

Test component interactions. Run in seconds.

E2E Tests (5-10%)

Full system workflows. Run in minutes.

Rule of thumb: The lower the test is in the pyramid, the faster and cheaper it is. Aim for a 70-20-10 distribution to balance speed and confidence.

Unit Tests

Unit tests verify individual functions or classes in isolation. They should be deterministic, fast, and independent of external systems.

unittest is included with Python. It's class-based and explicit, making test structure clear and familiar to developers from Java or C#.

import unittest

class Calculator:
    def add(self, a, b):
        return a + b
    
    def divide(self, a, b):
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

class TestCalculator(unittest.TestCase):
    
    def setUp(self):
        """Run before each test"""
        self.calc = Calculator()
    
    def test_add_positive_numbers(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)
    
    def test_add_negative_numbers(self):
        result = self.calc.add(-1, 1)
        self.assertEqual(result, 0)
    
    def test_divide_by_zero_raises_error(self):
        with self.assertRaises(ValueError):
            self.calc.divide(10, 0)

if __name__ == "__main__":
    unittest.main()
Featureunittestpytest
SetupBuilt-in, no installationpip install pytest
SyntaxClass-based, verboseFunction-based, concise
Assertionsself.assertEqual(), etc.Plain assert statements
FixturessetUp/tearDown methods@pytest.fixture decorator
ParametrizationManual loops or subtests@pytest.mark.parametrize
OutputBasicDetailed, colored
Best practices:
  • Unit tests should not touch the filesystem, network, or database
  • Each test should test one behavior or scenario
  • Tests should be independent - order shouldn't matter
  • Use descriptive test names: test_user_login_with_invalid_password_fails

Test Doubles: Mocks, Stubs & Fakes

Test doubles replace real dependencies to isolate the code under test.

Mock

Records calls and verifies interactions. Use when you care how something is called.

Stub

Returns predefined responses. Use when you need controlled test data.

Fake

Working implementation, but simplified. Example: in-memory database.

from unittest.mock import Mock, patch
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# Using Mock
def test_weather_service_with_mock():
    service = WeatherService()
    
    # Create a mock response
    mock_response = Mock()
    mock_response.json.return_value = {"temp": 72}
    
    # Patch requests.get to return our mock
    with patch('requests.get', return_value=mock_response) as mock_get:
        temp = service.get_temperature("Houston")
        assert temp == 72

        # Verify the API was called correctly
        mock_get.assert_called_once_with("https://api.weather.com/Houston")

# Using pytest-mock (cleaner syntax)
def test_weather_service_with_pytest_mock(mocker):
    service = WeatherService()
    
    # Stub the response
    mock_get = mocker.patch('requests.get')
    mock_get.return_value.json.return_value = {"temp": 72}
    
    temp = service.get_temperature("Houston")
    assert temp == 72
Caution: Over-mocking can make tests brittle. Only mock external dependencies (APIs, databases, file systems), not your own code.

Integration Tests

Integration tests ensure that multiple components work together correctly. They verify interactions between your code and real external systems.

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

# Using a test database
@pytest.fixture
def db_session():
    # Create test database
    engine = create_engine('sqlite:///:memory:')
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    session = Session()
    
    yield session
    
    # Cleanup
    session.close()

def test_user_repository_creates_user(db_session):
    # Integration test - actually talks to database
    user = User(name="Alice", email="alice@example.com")
    db_session.add(user)
    db_session.commit()
    
    # Query it back
    result = db_session.query(User).filter_by(name="Alice").first()
    assert result is not None
    assert result.email == "alice@example.com"

def test_user_repository_with_duplicate_email_fails(db_session):
    # Test business logic + database constraints
    user1 = User(name="Alice", email="test@example.com")
    user2 = User(name="Bob", email="test@example.com")
    
    db_session.add(user1)
    db_session.commit()
    
    db_session.add(user2)
    with pytest.raises(IntegrityError):
        db_session.commit()
Integration test patterns:
  • Test containers: Spin up real databases/services in Docker
  • In-memory databases: Fast but may miss database-specific bugs
  • Test fixtures: Pre-populate test data for consistent scenarios
Trade-off: Slower than unit tests (seconds vs milliseconds), but catch real-world integration issues like SQL syntax errors, transaction problems, and data type mismatches.

Functional & End-to-End Tests

E2E tests validate complete user workflows from the outside-in, treating your application as a black box and testing it like a real user would.

# API E2E Test (using pytest + requests)
def test_complete_user_signup_flow(api_client):
    # User signs up
    response = api_client.post("/api/signup", json={
        "email": "newuser@example.com",
        "password": "SecurePass123!",
        "name": "New User"
    })
    assert response.status_code == 201
    user_id = response.json()["user_id"]
    
    # User receives confirmation email (check mock email service)
    emails = get_sent_emails()
    assert len(emails) == 1
    assert "newuser@example.com" in emails[0]["to"]
    
    # User logs in
    response = api_client.post("/api/login", json={
        "email": "newuser@example.com",
        "password": "SecurePass123!"
    })
    assert response.status_code == 200
    token = response.json()["token"]
    
    # User accesses protected resource
    response = api_client.get(
        "/api/profile",
        headers={"Authorization": f"Bearer {token}"}
    )
    assert response.status_code == 200
    assert response.json()["name"] == "New User"

# Web UI E2E Test (using Selenium)
from selenium import webdriver
from selenium.webdriver.common.by import By

def test_user_can_complete_checkout(browser):
    # Navigate to product page
    browser.get("https://example.com/products/widget")
    
    # Add to cart
    add_button = browser.find_element(By.ID, "add-to-cart")
    add_button.click()
    
    # Go to checkout
    browser.get("https://example.com/checkout")
    
    # Fill in form
    browser.find_element(By.ID, "email").send_keys("buyer@example.com")
    browser.find_element(By.ID, "card-number").send_keys("4242424242424242")
    
    # Submit order
    browser.find_element(By.ID, "submit-order").click()
    
    # Verify confirmation
    confirmation = browser.find_element(By.CLASS_NAME, "order-confirmation")
    assert "Thank you" in confirmation.text
API E2E Tests

Tools: pytest, requests, FastAPI TestClient

Test backend workflows, authentication, business logic flows

UI E2E Tests

Tools: Selenium, Playwright, Cypress

Test user interfaces, browser interactions, visual workflows

Think like a user: E2E tests verify behavior and business value, not implementation details. They should break when user-facing functionality breaks, not when code structure changes.

Test Coverage

Code coverage measures which lines of code are executed during testing.

# Install coverage tool
pip install pytest-cov

# Run tests with coverage
pytest --cov=myapp --cov-report=html

# View detailed HTML report
# Coverage report saved to htmlcov/index.html

# Example output:
# Name                 Stmts   Miss  Cover
# ----------------------------------------
# myapp/__init__.py       10      0   100%
# myapp/models.py         45      3    93%
# myapp/views.py          67     12    82%
# myapp/utils.py          23      0   100%
# ----------------------------------------
# TOTAL                  145     15    90%
Important caveat: 100% coverage doesn't mean bug-free code. It only means every line was executed, not that every scenario was tested. Focus on testing meaningful behaviors, not just hitting coverage numbers.

That said, coverage reports are valuable for a different reason: they reveal unreachable paths and dead code - branches that can never be triggered, conditions that are always true, or functions nobody calls. Achieving 100% coverage won't guarantee correctness, but it does confirm that every line of your code is reachable, every requirement has at least one test exercising it, and you have no dead code silently sitting in your codebase.

Load & Performance Testing

Performance tests evaluate how your system behaves under expected and extreme load conditions.

# Using Locust for load testing
from locust import HttpUser, task, between

class WebsiteUser(HttpUser):
    wait_time = between(1, 3)  # Wait 1-3 seconds between tasks
    
    @task(3)  # Run this task 3x more often than other tasks
    def view_products(self):
        self.client.get("/products")
    
    @task(1)
    def view_product_detail(self):
        product_id = random.randint(1, 100)
        self.client.get(f"/products/{product_id}")
    
    @task(1)
    def add_to_cart(self):
        self.client.post("/cart", json={
            "product_id": random.randint(1, 100),
            "quantity": 1
        })

# Run with: locust -f loadtest.py --host=https://example.com
# Opens web UI at http://localhost:8089 to configure users and spawn rate
Load Testing

Simulates expected traffic to verify system handles normal load. Goal: Confirm performance under typical conditions.

Stress Testing

Pushes system beyond normal capacity to find breaking points. Goal: Identify maximum capacity and failure modes.

Key metrics to monitor:
  • Response time: P50, P95, P99 latencies
  • Throughput: Requests per second
  • Error rate: Percentage of failed requests
  • Resource usage: CPU, memory, database connections
Common tools: Locust (Python), JMeter (Java), k6 (JavaScript), Apache Bench (CLI)

Security & Penetration Testing

Security testing identifies vulnerabilities before attackers do. It should be integrated throughout development, not just at the end.

Common Vulnerabilities
  • SQL Injection
  • Cross-Site Scripting (XSS)
  • Authentication bypass
  • Insecure dependencies
  • Sensitive data exposure
  • Missing rate limiting
Security Testing Tools
  • Bandit: Python code scanner
  • Safety: Dependency checker
  • OWASP ZAP: Web app scanner
  • SQLMap: SQL injection tester
  • Snyk: Vulnerability scanner
# Test for SQL injection vulnerability
def test_sql_injection_prevention():
    # Malicious input attempting SQL injection
    malicious_input = "1' OR '1'='1"
    
    # Should be safely handled by parameterized queries
    result = db.execute(
        "SELECT * FROM users WHERE id = ?", 
        (malicious_input,)
    )
    
    # Should return no results, not all users
    assert result.rowcount == 0

# Test authentication
def test_requires_authentication():
    response = client.get("/api/profile")
    assert response.status_code == 401
    
    response = client.get(
        "/api/profile",
        headers={"Authorization": "Bearer invalid_token"}
    )
    assert response.status_code == 401

# Test rate limiting
def test_rate_limiting():
    # Make 100 rapid requests
    responses = [client.post("/api/login") for _ in range(100)]
    
    # Some should be rate limited
    status_codes = [r.status_code for r in responses]
    assert 429 in status_codes  # Too Many Requests
Important: Security is a process, not a one-time test. Continuously scan dependencies, perform regular penetration tests, and stay updated on new vulnerabilities.

Debugging Techniques

Effective debugging is a critical skill. Here are professional techniques that will save you hours of frustration.

1. The Python Debugger (pdb)
import pdb

def calculate_discount(price, discount_percent):
    pdb.set_trace()  # Debugger will pause here
    discount = price * (discount_percent / 100)
    final_price = price - discount
    return final_price

result = calculate_discount(100, 20)

# When execution hits pdb.set_trace():
# (Pdb) p price          # Print variable
# 100
# (Pdb) p discount_percent
# 20
# (Pdb) n                # Next line
# (Pdb) p discount       # Check calculated value
# 20.0
# (Pdb) c                # Continue execution

Common pdb commands:

  • n - Next line
  • s - Step into function
  • c - Continue execution
  • p variable - Print variable
  • l - List source code
  • w - Where am I? (stack)
  • q - Quit debugger
  • h - Help
2. Logging for Debugging
import logging

# Configure logging
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)

logger = logging.getLogger(__name__)

def process_order(order_id, items):
    logger.debug(f"Processing order {order_id} with {len(items)} items")
    
    total = sum(item['price'] for item in items)
    logger.info(f"Order {order_id} total: {total}")
    
    if total > 10000:
        logger.warning(f"Large order detected: {order_id}")
    
    try:
        charge_payment(total)
        logger.info(f"Payment successful for order {order_id}")
    except PaymentError as e:
        logger.error(f"Payment failed for order {order_id}: {e}")
        raise

# Output:
# 2026-01-03 10:30:45 - __main__ - DEBUG - Processing order 12345 with 3 items
# 2026-01-03 10:30:45 - __main__ - INFO - Order 12345 total: 450
# 2026-01-03 10:30:46 - __main__ - INFO - Payment successful for order 12345
3. IDE Debugger (Visual Debugging)

Modern IDEs (PyCharm, VS Code) provide visual debuggers with:

  • Breakpoints: Click line number to pause execution
  • Variable inspection: Hover to see values
  • Watch expressions: Monitor specific variables
  • Call stack: Navigate function call hierarchy
  • Conditional breakpoints: Break only when condition is true
4. The Debugging Mindset
✓ Do This
  • Reproduce the bug consistently
  • Form a hypothesis before debugging
  • Use binary search (divide and conquer)
  • Read error messages carefully
  • Check assumptions with assertions
  • Take breaks when stuck
✗ Avoid This
  • Random code changes hoping it fixes
  • Ignoring error messages
  • Debugging without understanding the code
  • Not testing your fix thoroughly
  • Leaving debug code in production
  • Debugging tired or frustrated
Pro tip: The best debugging tool is a good night's sleep. Your brain continues problem-solving subconsciously. Many developers solve bugs in the shower or during a walk after struggling for hours at the computer.

Test-Driven Development (TDD)

TDD is a development methodology where you write tests before writing the implementation code. It follows a simple cycle: Red → Green → Refactor.

🔴
Red

Write a failing test that defines desired behavior

🟢
Green

Write minimal code to make the test pass

🔵
Refactor

Improve code quality while keeping tests green

# TDD Example: Building a shopping cart

# Step 1: RED - Write failing test
def test_empty_cart_has_zero_total():
    cart = ShoppingCart()
    assert cart.total() == 0
# Result: NameError: ShoppingCart is not defined ❌

# Step 2: GREEN - Make it pass (minimal code)
class ShoppingCart:
    def total(self):
        return 0

# Run test again: PASSES ✓

# Step 3: RED - Next test
def test_cart_with_one_item():
    cart = ShoppingCart()
    cart.add_item(Item("Book", 10.00))
    assert cart.total() == 10.00
# Result: AttributeError: 'ShoppingCart' has no attribute 'add_item' ❌

# Step 4: GREEN - Make it pass
class ShoppingCart:
    def __init__(self):
        self.items = []
    
    def add_item(self, item):
        self.items.append(item)
    
    def total(self):
        return sum(item.price for item in self.items)

# Run test: PASSES ✓

# Step 5: REFACTOR - Improve without breaking tests
# (Tests stay green throughout refactoring)
Benefits of TDD:
  • Forces you to think about requirements before coding
  • Produces better-designed, more testable code
  • Provides instant feedback and confidence
  • Built-in regression protection

Key Takeaways

  • Testing enables confident refactoring - change code without fear
  • Different test types serve different purposes - use the right tool for the job
  • Unit tests form the foundation - fast feedback is invaluable
  • Debugging is a learnable skill - systematic approach beats random changes
  • Coverage is a guide, not a goal - test behaviors, not lines of code
  • Security testing is continuous - integrate it throughout development
  • TDD changes how you design - tests first leads to better architecture

Practice Exercises

Exercise 1: Password Validator

Write unit tests for a password validation function that checks:

  • Minimum 8 characters
  • At least one uppercase letter
  • At least one number
  • At least one special character

Use pytest and test both valid and invalid passwords

Exercise 2: Integration Test

Create an integration test for a user registration system that:

  • Saves user to database
  • Prevents duplicate emails
  • Hashes passwords securely

Use an in-memory SQLite database for testing

Exercise 3: TDD Challenge

Use TDD to build a simple calculator class that supports:

  • Addition, subtraction, multiplication, division
  • Memory functions (store, recall, clear)
  • Division by zero error handling

Write tests first, then implement to make them pass

Additional Resources

  • Books: "Test Driven Development" by Kent Beck, "The Art of Unit Testing" by Roy Osherove
  • Documentation: pytest.org, docs.python.org/3/library/unittest.html
  • Tools: pytest-cov, pytest-mock, Hypothesis (property-based testing)
  • Videos: Search for "pytest tutorial" and "debugging techniques" on YouTube
What's Next?

With testing skills mastered, it's time to make your code faster! Learn how to profile and optimize performance.

  • Profiling Tools - Identify performance bottlenecks in your code
  • Optimization Techniques - Learn strategies to make Python code run faster
  • Memory Management - Understand and optimize memory usage