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 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.
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()| Feature | unittest | pytest |
|---|---|---|
| Setup | Built-in, no installation | pip install pytest |
| Syntax | Class-based, verbose | Function-based, concise |
| Assertions | self.assertEqual(), etc. | Plain assert statements |
| Fixtures | setUp/tearDown methods | @pytest.fixture decorator |
| Parametrization | Manual loops or subtests | @pytest.mark.parametrize |
| Output | Basic | Detailed, colored |
- 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 == 72Integration 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()- 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
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.textAPI 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
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%
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 rateLoad 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.
- Response time: P50, P95, P99 latencies
- Throughput: Requests per second
- Error rate: Percentage of failed requests
- Resource usage: CPU, memory, database connections
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 RequestsDebugging 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 executionCommon pdb commands:
n- Next lines- Step into functionc- Continue executionp variable- Print variable
l- List source codew- Where am I? (stack)q- Quit debuggerh- 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 123453. 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
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)- 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