Testing & Debugging

Build confidence through systematic testing and 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
  • Cut the time spent reproducing and locating bugs

In This Lesson

1. The Testing Pyramid

How much of each kind of test to write

2. Unit Tests

unittest vs pytest, side by side

3. Fixtures & Test Data

tmp_path, monkeypatch, capsys, conftest.py

4. Property-Based Testing

Let Hypothesis invent the inputs

5. Test Doubles

Mocks, stubs, fakes, and patching correctly

6. Integration Tests

Real databases and real wiring

7. Functional & E2E

Whole workflows, from the outside in

8. Coverage & Mutation

What coverage proves, and what it cannot

9. Load & Performance

Behavior under expected and extreme load

10. Security Testing

Finding vulnerabilities before attackers do

11. Debugging

pdb, logging, and a systematic method

12. TDD

Red, green, refactor

The Testing Pyramid

The testing pyramid: many unit tests at the base, fewer integration tests above them, and a few end-to-end tests at the topUnit 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 the distribution above: mostly unit tests, fewer integration tests, and a thin layer of E2E tests, so the suite stays fast without losing 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#.

# calculator.py - the production code under test
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


# test_calculator.py - tests live in their own module
import unittest

from calculator import Calculator


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()

# Run with: python -m unittest test_calculator
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
  • Structure each test as Arrange, Act, Assert: set up the world, do one thing, check one outcome

Fixtures & Test Data

Most of the work in a test suite is arranging the world before the assertion. pytest ships several fixtures for exactly that, so you rarely need to write temporary-directory or environment-variable plumbing yourself.

# app.py
import json
import os


def write_report(path, rows):
    path.write_text(json.dumps([{"name": n, "age": a} for n, a in rows]))


def get_region():
    return os.environ["APP_REGION"]


def print_summary(total):
    print(f"Total: {total}")


# test_app.py - pytest ships these fixtures, nothing to import or install
import json
import logging

from app import get_region, print_summary, write_report


def test_report_is_written_to_disk(tmp_path):
    """tmp_path: a fresh temporary directory per test, as a pathlib.Path"""
    output = tmp_path / "report.json"
    write_report(output, rows=[("alice", 30)])
    assert json.loads(output.read_text()) == [{"name": "alice", "age": 30}]


def test_region_comes_from_environment(monkeypatch):
    """monkeypatch: set variables or attributes, undone after the test"""
    monkeypatch.setenv("APP_REGION", "us-east-1")
    assert get_region() == "us-east-1"


def test_cli_prints_summary(capsys):
    """capsys: capture whatever the code wrote to stdout/stderr"""
    print_summary(total=42)
    assert "Total: 42" in capsys.readouterr().out


def test_large_order_is_logged(caplog):
    """caplog: assert on log records instead of scraping printed text"""
    with caplog.at_level(logging.WARNING):
        logging.getLogger("orders").warning("Large order detected: %s", 12345)
    assert "Large order detected: 12345" in caplog.text
Why these matter:
  • tmp_path gives each test its own directory, so file tests never collide or leak
  • monkeypatch undoes every change automatically, so one test cannot poison the next
  • caplog asserts on structured log records rather than scraping printed text

Your own fixtures belong in conftest.py, where pytest discovers them automatically for every test file in that directory and below.

# conftest.py - fixtures defined here are available to every test file in
# this directory and below, with no import required
import pytest

from myapp.clock import Clock
from myapp.db import Database


@pytest.fixture(scope="session")
def database():
    """scope='session': built once for the entire run (for expensive setup)"""
    db = Database.connect("sqlite:///:memory:")
    yield db
    db.close()


@pytest.fixture
def user(database):
    """Default scope='function': rebuilt fresh for every single test.
    Fixtures can depend on other fixtures, and pytest resolves the order."""
    return database.create_user(name="Alice")


@pytest.fixture(autouse=True)
def frozen_clock():
    """autouse=True: applied to every test in scope without being requested"""
    Clock.freeze("2026-01-01T00:00:00Z")
    yield
    Clock.reset()

# Useful command-line selection while developing:
#   pytest -k "signup"     run only tests whose name matches "signup"
#   pytest -x              stop at the first failure
#   pytest --lf            re-run only the tests that failed last time
#   pytest -m "not slow"   skip tests marked @pytest.mark.slow
Watch the scope: a session fixture is shared by every test that uses it, so any state a test mutates leaks into the tests that follow. Share expensive connections, not mutable data.

Property-Based Testing

Example-based tests only cover the inputs you thought of, and bugs live in the inputs you did not. Property-based testing inverts this: you state a rule that must always hold, and the library hunts for a counter-example.

# pip install hypothesis
import pytest
from hypothesis import given, strategies as st

from statistics_utils import calculate_average


# Example-based: you pick the inputs, so you only catch the cases you imagined
def test_average_of_known_values():
    assert calculate_average([10, 20, 30]) == 20.0


# Property-based: Hypothesis generates hundreds of inputs looking for a
# counter-example, then shrinks any failure to the smallest one that still fails
@given(st.lists(st.integers(min_value=-10**6, max_value=10**6), min_size=1))
def test_average_lies_between_min_and_max(numbers):
    assert min(numbers) <= calculate_average(numbers) <= max(numbers)


@given(
    st.lists(st.integers(min_value=-1000, max_value=1000), min_size=1),
    st.integers(min_value=-1000, max_value=1000),
)
def test_shifting_every_value_shifts_the_average(numbers, offset):
    shifted = [n + offset for n in numbers]
    # pytest.approx: never compare floating point results with ==
    assert calculate_average(shifted) == pytest.approx(
        calculate_average(numbers) + offset
    )

# Drop min_size=1 and Hypothesis hands you [] on the very first attempts,
# forcing you to decide what an empty input should actually do.
Good properties to look for
  • Round trips: decode(encode(x)) == x
  • Invariants: a sorted list has the same length and elements
  • Bounds: a result always lands inside a known range
  • Agreement: a fast implementation matches a slow, obviously correct one
Shrinking is the superpower

When Hypothesis finds a failure it does not report the random 500-element list it happened to generate. It shrinks the input down to the smallest case that still fails, which is usually small enough to debug by reading it.

Use both: keep example-based tests for the specific cases your users actually care about, and add properties for the rules that must never break. They catch different classes of bug.

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
Patch where it is used, not where it is defined

This is the mistake that costs beginners the most time: the patch appears to apply, the test runs, and the real dependency is called anyway. The rule follows directly from how import works.

from unittest.mock import patch

# The single most common mocking mistake: patching the wrong name.
# patch() replaces an attribute on a module, so you must patch the name in
# the module that LOOKS IT UP, not the module that defines it.

# --- myapp/weather.py ---
import requests  # the module object is stored; requests.get is resolved per call

def get_temperature(city):
    return requests.get(f"https://api.weather.com/{city}").json()["temp"]

# Works: the lookup happens at call time, so replacing requests.get is enough
patch("requests.get")


# --- myapp/report.py ---
from requests import get  # the function itself is copied into myapp.report NOW

def fetch_rows(url):
    return get(url).json()

# Does NOT work: myapp.report.get still points at the original function
patch("requests.get")

# Works: patch the copy that myapp.report actually calls
patch("myapp.report.get")


# Also prefer autospec, so a mock rejects calls the real object would reject
with patch("myapp.report.get", autospec=True) as mock_get:
    fetch_rows("https://example.com/rows")
    mock_get.assert_called_once_with("https://example.com/rows")
    # Without autospec, mock_get(1, 2, 3, nonsense=True) would happily pass
Caution: Over-mocking can make tests brittle. Only mock external dependencies (APIs, databases, file systems), not your own code. A test that mocks everything it touches asserts that your code calls the mocks, not that it works.

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, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker

from myapp.models import Base, User  # your SQLAlchemy declarative models


# 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()
    engine.dispose()


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 (SQLAlchemy 2.0 style: select() + scalars())
    result = db_session.scalars(
        select(User).where(User.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.
    # Requires User.email to be declared with unique=True
    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()

    # A failed commit leaves the session unusable until it is rolled back
    db_session.rollback()
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 (api_client is a pytest fixture wrapping requests or TestClient)
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
    # get_sent_emails() is a test helper that reads the mock email backend
    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.

Coverage & Mutation Testing

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.

Mutation testing: who tests the tests?

Coverage tells you a line ran. It cannot tell you whether any assertion would have noticed if that line were wrong. Mutation testing answers exactly that: it makes small changes to your source, one at a time, and re-runs your suite. A mutant that gets killed (the tests fail) is good news. A mutant that survives is a change your tests did not detect.

# pip install mutmut

# setup.cfg
#   [mutmut]
#   source_paths=myapp

# myapp/discounts.py - fully covered by one test
def apply_discount(price, percent):
    percent = min(percent, 50)  # never discount more than half
    return price - price * (percent / 100)

# tests/test_discounts.py
def test_apply_discount():
    assert apply_discount(100, 10) == 90

# Coverage is happy:
#   Name                 Stmts   Miss  Cover
#   myapp/discounts.py       3      0   100%

# Mutation testing is not:
#   $ mutmut run
#   10/10  killed 9  survived 1
#
#   $ mutmut results
#   myapp.discounts.x_apply_discount__mutmut_6: survived
#
#   $ mutmut show myapp.discounts.x_apply_discount__mutmut_6
#   -    percent = min(percent, 50)
#   +    percent = min(percent, 51)

# The cap is executed by the test but never verified, so changing it breaks
# nothing. That surviving mutant is the missing test:
#   assert apply_discount(100, 90) == 50
Read that carefully: the function above has 100% line coverage from a single test, and mutation testing still finds an untested behavior. Every surviving mutant is a concrete, specific missing assertion, which makes it far more actionable than a coverage percentage.
Cost: mutation testing re-runs your suite once per mutant, so it is slow. Run it on your most critical modules or on a nightly job, not on every commit.

Load & Performance Testing

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

# Using Locust for load testing
import random

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
  • pip-audit: Dependency checker (PyPA)
  • OWASP ZAP: Web app scanner
  • SQLMap: SQL injection tester
  • Snyk: Vulnerability scanner
# Test for SQL injection vulnerability
def test_sql_injection_prevention(db):
    # Malicious input attempting SQL injection
    malicious_input = "1' OR '1'='1"

    # Should be safely handled by parameterized queries
    cursor = db.execute(
        "SELECT * FROM users WHERE id = ?",
        (malicious_input,)
    )

    # Note: cursor.rowcount is -1 for SELECT in the DB-API, so assert on
    # the rows themselves, never on rowcount
    assert cursor.fetchall() == []

    # And the table is still intact: the input was treated as data, not SQL
    assert db.execute("SELECT COUNT(*) FROM users").fetchone()[0] > 0


# Test authentication
def test_requires_authentication(client):
    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(client):
    # 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)
def calculate_discount(price, discount_percent):
    breakpoint()  # Python 3.7+: drops into pdb at this line
    discount = price * (discount_percent / 100)
    final_price = price - discount
    return final_price

result = calculate_discount(100, 20)

# When execution hits breakpoint():
# (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

# Older code still uses the explicit form: import pdb; pdb.set_trace()
# breakpoint() is preferred because it honours the PYTHONBREAKPOINT
# variable, so PYTHONBREAKPOINT=0 disables every breakpoint at once.

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__)


class PaymentError(Exception):
    """Raised when a payment cannot be processed."""


def process_order(order_id, items):
    # Pass values as arguments instead of f-strings: the message is only
    # formatted if the level is actually enabled
    logger.debug("Processing order %s with %s items", order_id, len(items))

    total = sum(item['price'] for item in items)
    logger.info("Order %s total: %s", order_id, total)

    if total > 10000:
        logger.warning("Large order detected: %s", order_id)

    try:
        charge_payment(total)
        logger.info("Payment successful for order %s", order_id)
    except PaymentError:
        # .exception() logs at ERROR level and appends the traceback
        logger.exception("Payment failed for order %s", order_id)
        raise

# Output (%(asctime)s includes milliseconds):
# 2026-01-03 10:30:45,127 - __main__ - DEBUG - Processing order 12345 with 3 items
# 2026-01-03 10:30:45,131 - __main__ - INFO - Order 12345 total: 450
# 2026-01-03 10:30:46,042 - __main__ - INFO - Payment successful for order 12345

This is the debugging view of logging. For handlers, formatters, rotation, and structured output, see Lesson 12: Logging.

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: name '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
from dataclasses import dataclass
from decimal import Decimal

@dataclass
class Item:
    name: str
    price: Decimal  # Decimal, not float: money must not lose cents

def test_cart_with_one_item():
    cart = ShoppingCart()
    cart.add_item(Item("Book", Decimal("10.00")))
    assert cart.total() == Decimal("10.00")
# Result: AttributeError: 'ShoppingCart' object 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
  • Mutation testing grades your tests - a surviving mutant is a missing assertion
  • Properties find what examples miss - state the rule and let Hypothesis hunt for counter-examples
  • Patch where the name is looked up - the most common mocking bug is patching the wrong module
  • 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 (coverage), pytest-mock (mocking), Hypothesis (property-based testing), mutmut (mutation testing)
  • Going further: pytest-asyncio (async tests), freezegun (frozen clocks), schemathesis (API contract tests), tox/nox (multi-version runs)
  • 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