Python

Advanced Python

Testing & Logging

Manually running your program and eyeballing the output doesn't scale — it's slow, easy to forget, and doesn't protect you from regressions when you change code

JrCodex·8 min read

Jr Codex Python Notes

Level: Advanced Prerequisites: Chapter 4 Time to complete: ~30 minutes


Table of Contents

  1. Why Automated Testing?
  2. Writing Your First Test with pytest
  3. Testing for Exceptions
  4. Fixtures — Reusable Test Setup
  5. Parametrized Tests
  6. Mocking — Isolating What You Test
  7. The logging Module
  8. Logging Levels & Configuration
  9. Summary & Next Steps

1. Why Automated Testing?

Manually running your program and eyeballing the output doesn't scale — it's slow, easy to forget, and doesn't protect you from regressions when you change code later. Automated tests are code that checks your code, run in seconds, as often as you like.

def add(a, b):
    return a + b
 
# Manual "testing" — you'd have to remember to check this every time
print(add(2, 3))     # eyeball that this says 5
 
# Automated testing — the computer checks, and tells you immediately if it's wrong
assert add(2, 3) == 5
assert add(-1, 1) == 0

Recall from Module 3, Chapter 5 that assert shouldn't be used for production validation — but it's exactly the right tool inside tests, which are development-time checks, never shipped to run in production.


2. Writing Your First Test with pytest

pip install pytest
# calculator.py
def add(a, b):
    return a + b
 
def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
# test_calculator.py
from calculator import add, divide
 
def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0
    assert add(0, 0) == 0
 
def test_divide():
    assert divide(10, 2) == 5
    assert divide(9, 3) == 3
$ pytest test_calculator.py -v
test_calculator.py::test_add PASSED
test_calculator.py::test_divide PASSED
============ 2 passed in 0.01s ============
pytest Conventions
─────────────────────────────────────────
  Test FILES  must start with test_  or end with _test.py
  Test FUNCTIONS must start with test_
  pytest auto-discovers both — no manual registration needed
─────────────────────────────────────────

What a Failure Looks Like

def test_add_broken():
    assert add(2, 2) == 5     # deliberately wrong, to show failure output
$ pytest test_calculator.py -v
test_calculator.py::test_add_broken FAILED
 
    def test_add_broken():
>       assert add(2, 2) == 5
E       assert 4 == 5
E        +  where 4 = add(2, 2)

pytest shows exactly which values were compared — far more informative than a bare AssertionError.


3. Testing for Exceptions

Use pytest.raises to assert that a specific exception is raised — this is how you test the error-handling paths from Module 3, Chapter 4.

import pytest
from calculator import divide
 
def test_divide_by_zero_raises():
    with pytest.raises(ValueError):
        divide(10, 0)
 
def test_divide_by_zero_message():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

If divide(10, 0) does not raise a ValueError, the test fails — pytest.raises is itself a context manager (Chapter 3) that expects an exception to occur inside its with block.


4. Fixtures — Reusable Test Setup

A fixture provides reusable setup (and automatic teardown) for tests that need some starting state — think of it as dependency injection for tests.

import pytest
 
class ShoppingCart:
    def __init__(self):
        self.items = []
 
    def add_item(self, item, price):
        self.items.append((item, price))
 
    def total(self):
        return sum(price for _, price in self.items)
 
 
@pytest.fixture
def empty_cart():
    """Runs before each test that requests it; provides a fresh ShoppingCart."""
    return ShoppingCart()
 
def test_empty_cart_total(empty_cart):
    assert empty_cart.total() == 0
 
def test_add_item(empty_cart):
    empty_cart.add_item("Book", 15.99)
    assert empty_cart.total() == 15.99
 
def test_multiple_items(empty_cart):
    empty_cart.add_item("Book", 15.99)
    empty_cart.add_item("Pen", 2.50)
    assert empty_cart.total() == 18.49

Each test function that takes empty_cart as a parameter automatically gets a fresh ShoppingCart() — no leftover state carries over between tests, and there's no copy-pasted setup code.

Fixtures with Teardown

@pytest.fixture
def temp_file():
    path = "test_data.txt"
    with open(path, "w") as f:
        f.write("test content")
    yield path            # the TEST runs here, receiving `path`
    import os
    os.remove(path)          # cleanup — runs AFTER the test, success or failure
 
def test_file_content(temp_file):
    with open(temp_file) as f:
        assert f.read() == "test content"

This yield-based fixture pattern is the same idea as @contextmanager from Chapter 3 — setup before yield, teardown after.


5. Parametrized Tests

@pytest.mark.parametrize runs the same test function against multiple sets of inputs, avoiding repetitive near-duplicate test functions.

import pytest
from calculator import add
 
@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == expected
$ pytest test_calculator.py -v
test_calculator.py::test_add_parametrized[2-3-5] PASSED
test_calculator.py::test_add_parametrized[-1-1-0] PASSED
test_calculator.py::test_add_parametrized[0-0-0] PASSED
test_calculator.py::test_add_parametrized[100-200-300] PASSED

One test function, four independent test runs — each failure is reported separately with its exact inputs, making it obvious which case broke.


6. Mocking — Isolating What You Test

When code depends on something slow, unpredictable, or external (a network call, the current time, a database), you often want to test your logic without actually triggering that dependency. Mocking replaces the real thing with a fake, controllable stand-in.

from unittest.mock import Mock, patch
 
def get_weather(api_client, city):
    response = api_client.fetch(city)
    return f"{city}: {response['temp']}°C"
 
def test_get_weather():
    mock_client = Mock()                                  # a fake object
    mock_client.fetch.return_value = {"temp": 22}            # program its fake behavior
 
    result = get_weather(mock_client, "Boston")
    assert result == "Boston: 22°C"
    mock_client.fetch.assert_called_once_with("Boston")        # verify it was called correctly
# Patching a real function/module temporarily, for the duration of a test
import time
 
def slow_operation():
    time.sleep(5)          # in a real test, we do NOT want to actually wait 5 seconds!
    return "done"
 
@patch("time.sleep")            # replaces time.sleep with a mock, ONLY during this test
def test_slow_operation(mock_sleep):
    result = slow_operation()
    assert result == "done"
    mock_sleep.assert_called_once_with(5)      # confirm sleep WAS called, without actually waiting

Why this matters: tests should be fast, deterministic, and independent of external systems (real APIs, real clocks, real databases). Mocking lets you verify your code calls dependencies correctly, without depending on those dependencies actually being available or slow.


7. The logging Module

Module 3, Chapter 5 mentioned logging as the production-appropriate alternative to scattering print() statements. Here's how to actually use it.

import logging
 
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
 
def process_order(order_id):
    logger.info(f"Processing order {order_id}")
    try:
        # ... processing logic ...
        logger.info(f"Order {order_id} completed successfully")
    except Exception as e:
        logger.error(f"Order {order_id} failed: {e}")
 
process_order(1001)
# INFO:__main__:Processing order 1001
# INFO:__main__:Order 1001 completed successfully
print() vs logging
─────────────────────────────────────────
  print()    → always outputs, no severity, no timestamp, no easy way to disable
  logging    → leveled (DEBUG/INFO/WARNING/ERROR/CRITICAL), timestamped,
               can be redirected to files, filtered by severity, without editing code
─────────────────────────────────────────

8. Logging Levels & Configuration

import logging
 
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
 
logger.debug("Detailed diagnostic info")        # lowest severity
logger.info("General informational message")
logger.warning("Something unexpected, but not an error")
logger.error("A real error occurred")
logger.critical("A severe error — program may be unable to continue")
LevelUse For
DEBUGDetailed diagnostic info, useful only while actively debugging
INFOConfirmation that things are working as expected
WARNINGSomething unexpected happened, but the program can continue
ERRORA serious problem — some functionality failed
CRITICALA very serious error — the program itself may be unable to continue

Setting level=logging.INFO shows INFO and above (INFO, WARNING, ERROR, CRITICAL), but hides DEBUG — this is the mechanism that lets you leave detailed debug logging in your code permanently, only ever showing it when you explicitly turn the level down during troubleshooting.

# Logging to a file instead of the console
logging.basicConfig(
    filename="app.log",
    level=logging.WARNING,          # only WARNING and above get written
    format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
logger.info("This won't appear in app.log — below the WARNING threshold")
logger.warning("This WILL appear in app.log")

9. Summary & Next Steps

Key Takeaways

  • pytest auto-discovers test_*.py files and test_* functions; assert inside a test is exactly the intended use case (unlike production code, per Module 3, Chapter 5).
  • pytest.raises(SomeError) tests that an exception is correctly raised; fixtures (@pytest.fixture) provide clean, reusable setup/teardown; @pytest.mark.parametrize runs one test against many input sets.
  • Mocking (unittest.mock) replaces slow/external dependencies with controllable fakes, so tests stay fast and deterministic.
  • The logging module replaces ad-hoc print() debugging with leveled (DEBUG → CRITICAL), configurable, timestamped output — the standard for anything beyond a quick throwaway script.

Concept Check

  1. Why is using assert inside a pytest test appropriate, when Module 3 warned against using assert for production validation?
  2. What problem does a fixture solve compared to writing setup code inside every test function?
  3. Why would you use logging.warning() instead of print() in a real application?

Next Chapter

Chapter 6: Concurrency Basics


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index