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
Jr Codex Python Notes
Level: Advanced Prerequisites: Chapter 4 Time to complete: ~30 minutes
Table of Contents
- Why Automated Testing?
- Writing Your First Test with
pytest - Testing for Exceptions
- Fixtures — Reusable Test Setup
- Parametrized Tests
- Mocking — Isolating What You Test
- The
loggingModule - Logging Levels & Configuration
- 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) == 0Recall 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.49Each 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] PASSEDOne 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 waitingWhy 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 successfullyprint() 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")| Level | Use For |
|---|---|
DEBUG | Detailed diagnostic info, useful only while actively debugging |
INFO | Confirmation that things are working as expected |
WARNING | Something unexpected happened, but the program can continue |
ERROR | A serious problem — some functionality failed |
CRITICAL | A 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
pytestauto-discoverstest_*.pyfiles andtest_*functions;assertinside 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.parametrizeruns one test against many input sets.- Mocking (
unittest.mock) replaces slow/external dependencies with controllable fakes, so tests stay fast and deterministic. - The
loggingmodule replaces ad-hocprint()debugging with leveled (DEBUG → CRITICAL), configurable, timestamped output — the standard for anything beyond a quick throwaway script.
Concept Check
- Why is using
assertinside apytesttest appropriate, when Module 3 warned against usingassertfor production validation? - What problem does a fixture solve compared to writing setup code inside every test function?
- Why would you use
logging.warning()instead ofprint()in a real application?
Next Chapter
→ Chapter 6: Concurrency Basics
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index