What’s one thing you learned? What’s still confusing?
Building APIs with FastAPI
REST APIs with FastAPI, Pydantic validation, serving ML models, testing endpoints.
Reading Real Python Code: A FastAPI Service End-to-End
Read a complete 180-line FastAPI service line-by-line. Bridge from 'I know Python syntax' to 'I can read a codebase.'
Production Python: Logging, Config & CLI
Production-grade logging, config management, CLI tools (argparse/click/typer), packaging.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
You have a function test_add_numbers() that does assert add(2, 2) == 4. pytest runs it. The test fails. What does pytest display that plain python test_file.py would NOT show you?
unittest.TestCase:# unittest style — verbose, but you'll see this in legacy code
import unittest
class TestTemperatureConverter(unittest.TestCase):
def test_freezing(self):
self.assertEqual(celsius_to_fahrenheit(0), 32)
def test_boiling(self):
self.assertEqual(celsius_to_fahrenheit(100), 212)
def test_negative(self):
self.assertAlmostEqual(celsius_to_fahrenheit(-40), -40)
if __name__ == "__main__":
unittest.main()# pytest style — just functions, standard assert
def test_freezing():
assert celsius_to_fahrenheit(0) == 32
def test_boiling():
assert celsius_to_fahrenheit(100) == 212
def test_negative():
assert celsius_to_fahrenheit(-40) == pytest.approx(-40)| Feature | unittest | pytest |
|---|---|---|
| Boilerplate | Class + methods | Just functions |
| Assertion style | self.assertEqual, assertIn, ... | Standard assert |
| Failure messages | Generic | Shows actual values |
| Parametrize | Manual or ddt plugin | Built-in decorator |
| Fixtures | setUp / tearDown | @pytest.fixture (composable) |
| Plugin ecosystem | Minimal | 1000+ plugins |
pytest.pytest discovers tests by convention:
pytest Naming Conventions
| Pattern | Alternative | What pytest collects |
|---|---|---|
| test_*.py | *_test.py | Test files |
| test_*() | test_* methods | Test functions/methods |
# test_statistics.py
def mean(values: list[float]) -> float:
"""Calculate the arithmetic mean."""
if not values:
raise ValueError("Cannot compute mean of empty list")
return sum(values) / len(values)
def test_mean_basic():
assert mean([1, 2, 3]) == 2.0
def test_mean_single_element():
assert mean([42]) == 42.0
def test_mean_with_negatives():
assert mean([-1, 1]) == 0.0pytest test_statistics.py -vtest_statistics.py::test_mean_basic PASSED
test_statistics.py::test_mean_single_element PASSED
test_statistics.py::test_mean_with_negatives PASSED
This is the single most useful pytest feature for ML code. Instead of writing 10 near-identical test functions, you write one parametrized test.
# Repetitive — the pattern is the same, only inputs and expected change
def test_celsius_to_fahrenheit_freezing():
assert celsius_to_fahrenheit(0) == 32
def test_celsius_to_fahrenheit_boiling():
assert celsius_to_fahrenheit(100) == 212
def test_celsius_to_fahrenheit_body_temp():
assert celsius_to_fahrenheit(37) == pytest.approx(98.6, abs=0.1)
def test_celsius_to_fahrenheit_freezer():
assert celsius_to_fahrenheit(-18) == pytest.approx(0, abs=0.5)
def test_celsius_to_fahrenheit_negative_crossover():
assert celsius_to_fahrenheit(-40) == -40import pytest
@pytest.mark.parametrize("celsius,expected_f", [
(0, 32),
(100, 212),
(37, pytest.approx(98.6, abs=0.1)),
(-18, pytest.approx(0, abs=0.5)),
(-40, -40),
])
def test_celsius_to_fahrenheit(celsius, expected_f):
assert celsius_to_fahrenheit(celsius) == expected_fpytest generates a separate test ID for each case:
test_temp.py::test_celsius_to_fahrenheit[0-32] PASSED
test_temp.py::test_celsius_to_fahrenheit[100-212] PASSED
test_temp.py::test_celsius_to_fahrenheit[37-...] PASSED
test_temp.py::test_celsius_to_fahrenheit[-18-...] PASSED
test_temp.py::test_celsius_to_fahrenheit[-40--40] PASSED
If case 3 fails, you see exactly which input caused it — not just "the parametrized test failed."
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(100, -50, 50),
], ids=["positive", "zeros", "cancel", "mixed"])
def test_add(a, b, expected):
assert add(a, b) == expectedNamed IDs make the output more readable:
test_math.py::test_add[positive] PASSED
test_math.py::test_add[zeros] PASSED
@pytest.mark.parametrize("model_name", ["linear", "tree", "forest"])
def test_model_fits(model_name, sample_dataset):
model = build_model(model_name)
model.fit(sample_dataset["X"], sample_dataset["y"])
assert model.score(sample_dataset["X"], sample_dataset["y"]) > 0.5Fixtures provide test dependencies — database connections, sample data, temporary files — in a clean, composable way. They replace setUp/tearDown from unittest.
import pytest
import tempfile
import os
# ── Simple fixture: shared sample data ──────────────────────────────────────
@pytest.fixture
def sample_scores():
"""Provide a standard list of test scores for multiple tests."""
return [72, 85, 91, 68, 79, 88, 95, 62, 74, 83]
# ── Fixture with teardown using yield ───────────────────────────────────────
@pytest.fixture
def temp_csv_file():
"""Create a temporary CSV file, yield its path, then clean up."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f:
f.write("name,score\nAlice,92\nBob,78\nCarol,85\n")
filepath = f.name
yield filepath # ← test runs here with filepath available
os.unlink(filepath) # ← teardown: runs after the test finishes
# ── Using fixtures in tests ──────────────────────────────────────────────────
def test_mean_calculation(sample_scores):
result = mean(sample_scores)
assert result == pytest.approx(79.7, abs=0.1)
def test_median_calculation(sample_scores):
result = median(sample_scores)
assert result == pytest.approx(81.0, abs=0.1)
def test_csv_loading(temp_csv_file):
import pandas as pd
df = pd.read_csv(temp_csv_file)
assert len(df) == 3
assert "score" in df.columns# function scope (default): fixture runs fresh for every test function
@pytest.fixture
def fresh_database_connection():
conn = create_connection(":memory:")
conn.execute("CREATE TABLE users ...")
yield conn
conn.close()
# session scope: fixture runs ONCE for the entire test session
# Use for expensive setup: loading models, connecting to external services
@pytest.fixture(scope="session")
def ml_model():
"""Load an ML model once and share it across all tests in the session."""
print("\nLoading ML model (expensive -- only happens once)...")
model = load_model("model_weights.pkl")
yield model
# No teardown needed for a loaded model
# module scope: fixture runs once per test file
@pytest.fixture(scope="module")
def database_with_seed_data():
db = create_test_database()
db.seed(sample_users=100, sample_orders=500)
yield db
db.teardown()conftest.py are automatically available to all test files in the same directory and below — no import needed:conftest.py: Shared Fixtures
# tests/conftest.py
import pytest
import pandas as pd
@pytest.fixture(scope="session")
def sample_dataframe():
"""A standard sample DataFrame available to all test files."""
return pd.DataFrame({
"user_id": range(1, 101),
"feature_a": [float(i) * 1.5 for i in range(100)],
"feature_b": [i % 3 for i in range(100)],
"label": [i % 2 for i in range(100)],
})import pytest
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
# ── Basic exception check ────────────────────────────────────────────────────
def test_divide_by_zero_raises():
with pytest.raises(ValueError):
divide(10, 0)
# ── Check the exception message ──────────────────────────────────────────────
def test_divide_by_zero_message():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
# ── Check the exception type is specific ────────────────────────────────────
def test_divide_by_zero_is_value_error_not_generic():
with pytest.raises(ValueError): # NOT just Exception
divide(10, 0)
# ── Capture the exception to inspect it ─────────────────────────────────────
def test_divide_by_zero_exception_details():
with pytest.raises(ValueError) as exc_info:
divide(10, 0)
assert "zero" in str(exc_info.value).lower()
# ── Parametrize exception tests ──────────────────────────────────────────────
@pytest.mark.parametrize("value,error_type,error_match", [
(None, TypeError, "must be"),
(-1, ValueError, "must be positive"),
(1001, ValueError, "must not exceed 1000"),
])
def test_validate_input_errors(value, error_type, error_match):
with pytest.raises(error_type, match=error_match):
validate_input(value)This is essential for any numerical or ML code.
# This FAILS — floating-point arithmetic is not exact
assert 0.1 + 0.2 == 0.3 # AssertionError! (0.1+0.2 = 0.30000000000000004)
# This PASSES — pytest.approx handles the floating-point imprecision
assert 0.1 + 0.2 == pytest.approx(0.3)
# Control the tolerance
assert 3.14159 == pytest.approx(3.14, abs=0.01) # absolute tolerance
assert 1234.56 == pytest.approx(1234, rel=1e-3) # relative tolerance (0.1%)
# Works with lists and numpy arrays
import numpy as np
assert np.array([1.0, 2.0, 3.0]) == pytest.approx([1.0, 2.0, 3.0])
# ML model accuracy check
accuracy = model.score(X_test, y_test)
assert accuracy == pytest.approx(0.92, abs=0.02) # 92% ± 2%# Real example: testing a normalization function
def normalize(values: list[float]) -> list[float]:
"""Normalize values to mean=0, std=1."""
import statistics
mean = statistics.mean(values)
std = statistics.stdev(values)
return [(x - mean) / std for x in values]
def test_normalize_mean_is_zero():
result = normalize([1.0, 2.0, 3.0, 4.0, 5.0])
assert sum(result) / len(result) == pytest.approx(0.0, abs=1e-10)
def test_normalize_std_is_one():
import statistics
result = normalize([1.0, 2.0, 3.0, 4.0, 5.0])
assert statistics.stdev(result) == pytest.approx(1.0, rel=1e-6)import pytest
# Mark slow tests — skip in fast CI, run manually before release
@pytest.mark.slow
def test_train_full_model():
model = train_on_full_dataset() # takes 5 minutes
assert model.accuracy > 0.90
# Mark integration tests — require external services
@pytest.mark.integration
def test_database_connection():
conn = connect_to_production_db()
assert conn.ping() is True
# Mark expected failures (known bug, open ticket)
@pytest.mark.xfail(reason="Bug #1234: edge case in normalization")
def test_normalize_all_same_values():
result = normalize([5.0, 5.0, 5.0]) # std is 0 — division by zero
assert result == [0.0, 0.0, 0.0]
# Skip a test entirely
@pytest.mark.skip(reason="GPU not available in CI")
def test_gpu_training():
...pyproject.toml to avoid warnings:[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (run with -m slow)",
"integration: marks tests requiring external services",
]
Run specific groups:
pytest -m "not slow" # skip slow tests in CI
pytest -m slow # run only slow tests
pytest -m "integration and not slow"
pytest -v -k "temperature" # run tests with "temperature" in their name
monkeypatch is a built-in pytest fixture that lets you replace attributes, environment variables, and dictionary values for the duration of a single test. It always restores the original value afterwards, so tests remain isolated.import os
def get_api_url() -> str:
"""Read the API base URL from the environment."""
return os.environ.get("API_URL", "https://api.production.example.com")
def test_api_url_from_environment(monkeypatch):
"""Test that the function reads from the environment."""
monkeypatch.setenv("API_URL", "https://api.staging.example.com")
assert get_api_url() == "https://api.staging.example.com"
# After the test, API_URL is restored to its original value
def test_api_url_default(monkeypatch):
"""Test the default value when the environment variable is not set."""
monkeypatch.delenv("API_URL", raising=False) # remove it if it exists
assert get_api_url() == "https://api.production.example.com"import time
def measure_latency(fn, *args) -> float:
"""Measure how long fn(*args) takes in seconds."""
start = time.time()
fn(*args)
return time.time() - start
def test_measure_latency(monkeypatch):
"""Test measure_latency without actually sleeping."""
call_count = {"n": 0}
# Replace time.time with a controlled version
fake_times = iter([100.0, 100.5]) # start=100.0, end=100.5
def fake_time():
return next(fake_times)
monkeypatch.setattr(time, "time", fake_time)
latency = measure_latency(lambda: None)
assert latency == pytest.approx(0.5, abs=1e-9)# database.py
class UserRepository:
def get_user(self, user_id: int) -> dict:
"""Fetch a user from the real database."""
# ... real DB call here ...
raise NotImplementedError("Would hit the real DB")
# Code under test
def get_user_display_name(repo: UserRepository, user_id: int) -> str:
user = repo.get_user(user_id)
return f"{user['first_name']} {user['last_name']}"
# test_service.py
def test_get_user_display_name(monkeypatch):
"""Test display name formatting without hitting the database."""
def fake_get_user(self, user_id):
return {"first_name": "Alice", "last_name": "Smith", "id": user_id}
monkeypatch.setattr(UserRepository, "get_user", fake_get_user)
repo = UserRepository()
result = get_user_display_name(repo, 42)
assert result == "Alice Smith"Coverage tells you which lines of your source code are executed by your tests. It does not tell you whether your tests are good — but it tells you which code is NOT tested at all.
# Install
pip install pytest-cov
# Run tests with coverage
pytest --cov=src --cov-report=term-missing
# Generate HTML report (open htmlcov/index.html)
pytest --cov=src --cov-report=html
# Fail if coverage drops below 80%
pytest --cov=src --cov-fail-under=80
Example output:
Name Stmts Miss Cover Missing
------------------------------------------------------
src/statistics.py 24 3 88% 45, 67-68
src/models/linear.py 48 8 83% 102-109
src/pipeline.py 91 21 77% 67-87
------------------------------------------------------
TOTAL 163 32 80%
else branch or a raise statement.Tests · Run pytest -v to see each parametrized case with its ID. Verify test_mean_empty_raises catches the ValueError. Verify test_normalize_properties passes with pytest.approx tolerance.
You have 7 test functions, each calling the same function with a different input. What is the pytest way to clean this up?
Interactive Lab
Watch pytest discover, collect, and run tests — see fixture scope and parametrize in action
==, in, len(), and for syntax you use in every test.