Why Testing Matters (and Why pytest)
Tests catch bugs before users do. pytest makes writing them fast enough that you'll actually do it.
pip install pytest
Run with: pytest (auto-discovers test_*.py files)
Basic Test Structure
# test_calculator.py
def add(a: int, b: int) -> int:
return a + b
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
def test_add_zero():
assert add(0, 5) == 5
Run: pytest test_calculator.py -v
test_calculator.py::test_add_positive PASSED
test_calculator.py::test_add_negative PASSED
test_calculator.py::test_add_zero PASSED
3 passed in 0.01s
Testing Exceptions
import pytest
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_divide_normal():
assert divide(10, 2) == 5.0
Fixtures: Reusable Setup
Fixtures provide test data or objects without repeating setup code:
import pytest
class UserDB:
def __init__(self):
self.users = {}
def add_user(self, name: str, email: str):
self.users[email] = {"name": name, "email": email}
def get_user(self, email: str) -> dict | None:
return self.users.get(email)
@pytest.fixture
def db():
"""Fresh database for each test."""
return UserDB()
@pytest.fixture
def db_with_user(db):
"""Database pre-loaded with a test user."""
db.add_user("Alice", "alice@example.com")
return db
def test_add_user(db):
db.add_user("Bob", "bob@example.com")
assert db.get_user("bob@example.com")["name"] == "Bob"
def test_get_existing_user(db_with_user):
user = db_with_user.get_user("alice@example.com")
assert user["name"] == "Alice"
def test_get_missing_user(db):
assert db.get_user("nobody@example.com") is None
Each test gets a fresh db — no state leaks between tests.
Parametrize: Test Multiple Inputs
import pytest
@pytest.mark.parametrize("a, b, expected", [
(2, 3, 5),
(-1, 1, 0),
(0, 0, 0),
(100, -50, 50),
])
def test_add(a, b, expected):
assert add(a, b) == expected
One test function, four test cases. pytest runs and reports each separately.
Mocking with monkeypatch
import requests
def get_user_name(user_id: int) -> str:
r = requests.get(f"https://api.example.com/users/{user_id}")
return r.json()["name"]
def test_get_user_name(monkeypatch):
class FakeResponse:
def json(self):
return {"name": "Alice", "id": 1}
def fake_get(url):
return FakeResponse()
monkeypatch.setattr(requests, "get", fake_get)
result = get_user_name(1)
assert result == "Alice"
The real requests.get never fires — your test runs offline and fast.
Using pytest-mock for Cleaner Mocks
pip install pytest-mock
def test_get_user_name(mocker):
mock_get = mocker.patch("requests.get")
mock_get.return_value.json.return_value = {"name": "Alice"}
result = get_user_name(1)
assert result == "Alice"
mock_get.assert_called_once_with("https://api.example.com/users/1")
Test Organization
project/
├── src/
│ └── myapp/
│ ├── __init__.py
│ ├── calculator.py
│ └── users.py
└── tests/
├── conftest.py # Shared fixtures
├── test_calculator.py
└── test_users.py
conftest.py — fixtures available to all test files without importing:
# tests/conftest.py
import pytest
from myapp.users import UserDB
@pytest.fixture
def empty_db():
return UserDB()
@pytest.fixture
def populated_db(empty_db):
empty_db.add_user("Alice", "alice@example.com")
empty_db.add_user("Bob", "bob@example.com")
return empty_db
Useful pytest Options
pytest # Run all tests
pytest -v # Verbose output
pytest -k "test_add" # Run tests matching pattern
pytest --tb=short # Shorter tracebacks
pytest -x # Stop on first failure
pytest --lf # Run only last-failed tests
pytest -s # Show print() output
pytest --cov=src # Coverage report (needs pytest-cov)
What to Test
Test these:
- Happy path (valid inputs, expected output)
- Edge cases (empty list, zero, None, empty string)
- Error conditions (invalid input raises exception)
- Boundary values (min/max for numeric inputs)
Don't test:
- Python itself (
assert 1 + 1 == 2) - Third-party library internals
- Private implementation details that change often
Further Reading
Get the Full Pipeline
This article is part of the Python AI Publishing Pipeline series — a complete system to write, validate, and publish technical ebooks with Python and Claude.
📋 Free checklist: 7 steps to ship a Python ebook — PDF, no email required.
🚀 Full pipeline + source code: germy5.gumroad.com/l/xhxkzz — $9.99, 30-day money-back guarantee.
If this was useful, the ❤️ button helps other developers find it.
Building a Python content pipeline? I sell the complete automation system as a one-time download — Dev.to API, Claude API, launchd, Gumroad. Check it out ($9.99)
Top comments (0)