Python Testing with Pytest: Write Tests That Actually Catch Bugs
A practical pytest tutorial: write Python tests that catch real bugs, use fixtures, mock dependencies, and build a test suite that gives you confidence to ship.
Get more content like this on Telegram!
Daily AI tips, notes & resources — free
Python Testing with Pytest: Write Tests That Actually Catch Bugs
I used to think writing tests was busy work. Then I refactored a 200-line function without tests, shipped it, and spent a Friday evening fixing three production bugs that tests would have caught in 3 seconds.
Writing tests isn't about following best practices. It's about not having bad Fridays.
This tutorial teaches pytest from scratch with a focus on writing tests that actually find bugs — not just tests that pass.
Setup
pip install pytest pytest-mock
Pytest discovers tests automatically: any file starting with test_ or ending with _test.py, and any function starting with test_.
my_project/
src/
calculator.py
user_service.py
tests/
test_calculator.py
test_user_service.py
Run all tests: pytest
Run specific file: pytest tests/test_calculator.py
Run with verbose output: pytest -v
Your First Tests
Let's test a simple calculator module:
# src/calculator.py
def add(a: float, b: float) -> float:
return a + b
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def percentage(value: float, percent: float) -> float:
return value * (percent / 100)
# tests/test_calculator.py
import pytest
from src.calculator import add, divide, percentage
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
def test_add_zero():
assert add(0, 5) == 5
def test_divide_normal():
assert divide(10, 2) == 5.0
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
def test_divide_returns_float():
result = divide(7, 2)
assert result == 3.5
def test_percentage():
assert percentage(200, 10) == 20.0
assert percentage(100, 100) == 100.0
assert percentage(50, 50) == 25.0
Run: pytest tests/test_calculator.py -v
Understanding Assertions
# Basic equality
assert result == expected
# Floating point (never use == for floats)
assert abs(result - expected) < 1e-9
# Or use pytest's approx:
assert result == pytest.approx(expected, rel=1e-6)
assert 0.1 + 0.2 == pytest.approx(0.3)
# Sequences
assert result == [1, 2, 3]
assert 42 in result_list
# Exceptions
with pytest.raises(TypeError):
function_that_should_raise()
# Exception message
with pytest.raises(ValueError, match="specific error text"):
function_raising_value_error()
# None checks
assert result is None
assert result is not None
Testing a Class
# src/user.py
class User:
def __init__(self, name: str, email: str, age: int):
if age < 0:
raise ValueError("Age cannot be negative")
if "@" not in email:
raise ValueError("Invalid email format")
self.name = name
self.email = email
self.age = age
self._purchases = []
def add_purchase(self, item: str, amount: float):
if amount <= 0:
raise ValueError("Purchase amount must be positive")
self._purchases.append({"item": item, "amount": amount})
@property
def total_spent(self) -> float:
return sum(p["amount"] for p in self._purchases)
def is_big_spender(self, threshold: float = 1000) -> bool:
return self.total_spent >= threshold
# tests/test_user.py
import pytest
from src.user import User
class TestUserCreation:
def test_valid_user(self):
user = User("Alice", "alice@example.com", 30)
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.age == 30
def test_negative_age_raises(self):
with pytest.raises(ValueError, match="Age cannot be negative"):
User("Bob", "bob@example.com", -1)
def test_invalid_email_raises(self):
with pytest.raises(ValueError, match="Invalid email format"):
User("Carol", "not-an-email", 25)
class TestUserPurchases:
def test_add_purchase(self):
user = User("Alice", "alice@example.com", 30)
user.add_purchase("Laptop", 999.99)
assert len(user._purchases) == 1
assert user.total_spent == 999.99
def test_multiple_purchases(self):
user = User("Alice", "alice@example.com", 30)
user.add_purchase("Laptop", 999.99)
user.add_purchase("Mouse", 29.99)
assert user.total_spent == pytest.approx(1029.98)
def test_zero_purchase_raises(self):
user = User("Alice", "alice@example.com", 30)
with pytest.raises(ValueError):
user.add_purchase("Free item", 0)
def test_is_big_spender_false(self):
user = User("Alice", "alice@example.com", 30)
user.add_purchase("Mouse", 29.99)
assert user.is_big_spender() is False
def test_is_big_spender_true(self):
user = User("Alice", "alice@example.com", 30)
user.add_purchase("Laptop", 1500)
assert user.is_big_spender() is True
Fixtures — Reusable Setup
# tests/test_user.py (with fixtures)
import pytest
from src.user import User
@pytest.fixture
def basic_user():
"""A basic user with no purchases."""
return User("Alice", "alice@example.com", 30)
@pytest.fixture
def user_with_purchases(basic_user):
"""A user who has already made purchases."""
basic_user.add_purchase("Laptop", 999.99)
basic_user.add_purchase("Mouse", 29.99)
return basic_user
def test_total_spent(user_with_purchases):
assert user_with_purchases.total_spent == pytest.approx(1029.98)
def test_big_spender_check(user_with_purchases):
assert user_with_purchases.is_big_spender() is True
def test_fresh_user_no_purchases(basic_user):
assert basic_user.total_spent == 0
Fixtures can have setup AND teardown:
@pytest.fixture
def temp_database():
db = create_test_database()
yield db # Test runs here
db.cleanup() # Teardown — runs after test regardless of pass/fail
Parameterized Tests
Test the same logic with multiple inputs:
import pytest
from src.calculator import add
@pytest.mark.parametrize("a, b, expected", [
(1, 2, 3),
(-1, 1, 0),
(0, 0, 0),
(100, -50, 50),
(0.1, 0.2, 0.3),
])
def test_add(a, b, expected):
assert add(a, b) == pytest.approx(expected)
This runs 5 tests in one block — each with different inputs. Much cleaner than 5 separate functions.
Mocking External Dependencies
Real tests don't call real APIs. Use mocks to control what external functions return:
# src/weather.py
import requests
def get_temperature(city: str) -> float:
response = requests.get(f"https://api.weather.com/{city}")
response.raise_for_status()
return response.json()["temperature"]
def is_hot(city: str, threshold: float = 30) -> bool:
temp = get_temperature(city)
return temp >= threshold
# tests/test_weather.py
import pytest
from unittest.mock import Mock, patch
from src.weather import get_temperature, is_hot
def test_get_temperature(mocker):
# Mock the requests.get call
mock_response = Mock()
mock_response.json.return_value = {"temperature": 25.5}
mock_response.raise_for_status.return_value = None
mocker.patch("src.weather.requests.get", return_value=mock_response)
temp = get_temperature("London")
assert temp == 25.5
def test_is_hot_true(mocker):
mocker.patch("src.weather.get_temperature", return_value=35.0)
assert is_hot("Dubai") is True
def test_is_hot_false(mocker):
mocker.patch("src.weather.get_temperature", return_value=15.0)
assert is_hot("London") is False
def test_api_error_propagates(mocker):
import requests
mocker.patch("src.weather.requests.get",
side_effect=requests.ConnectionError("Network error"))
with pytest.raises(requests.ConnectionError):
get_temperature("offline_city")
Testing a FastAPI Endpoint
# src/api.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class TaskCreate(BaseModel):
title: str
tasks = []
@app.post("/tasks", status_code=201)
def create_task(task: TaskCreate):
new_task = {"id": len(tasks) + 1, "title": task.title}
tasks.append(new_task)
return new_task
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
for task in tasks:
if task["id"] == task_id:
return task
raise HTTPException(status_code=404, detail="Task not found")
# tests/test_api.py
import pytest
from fastapi.testclient import TestClient
from src.api import app, tasks
@pytest.fixture(autouse=True)
def clear_tasks():
tasks.clear()
yield
tasks.clear()
@pytest.fixture
def client():
return TestClient(app)
def test_create_task(client):
response = client.post("/tasks", json={"title": "Learn pytest"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Learn pytest"
assert "id" in data
def test_get_task(client):
client.post("/tasks", json={"title": "Test task"})
response = client.get("/tasks/1")
assert response.status_code == 200
assert response.json()["title"] == "Test task"
def test_get_nonexistent_task(client):
response = client.get("/tasks/999")
assert response.status_code == 404
For the FastAPI app these tests cover, see our FastAPI tutorial.
Test Coverage
pip install pytest-cov
# Run tests with coverage report
pytest --cov=src --cov-report=term-missing
# Generate HTML report
pytest --cov=src --cov-report=html
Coverage shows which lines aren't tested. Aim for 80%+ coverage on business logic.
Frequently Asked Questions
Why write tests?
Catch bugs before production, enable safe refactoring, document expected behavior. Tests pay back quickly on any project you'll modify more than once.
Unit vs integration tests?
Unit tests: isolated functions, mock dependencies. Integration tests: real databases/services. Good suites have both.
What is a pytest fixture?
A reusable setup function that provides data or objects to tests. Defined with @pytest.fixture, injected by name into test functions.
How to test code that calls APIs?
Use mocking to replace the API call with a controlled fake. mocker.patch() from pytest-mock is the clean approach.
Final Thoughts
Writing good tests is a skill that takes practice. The hardest part isn't the pytest syntax — it's learning to think in terms of "what could go wrong here?" and "what edge cases haven't I considered?"
Start small: write tests for one function today. Any function you've written that you're not confident is correct. You'll either confirm it works or discover a bug. Both outcomes are valuable.
For the Python OOP patterns that make code more testable, our Python OOP tutorial covers designing classes with testing in mind. And for the automation workflows that run tests in CI/CD pipelines, our Python automation scripts guide covers scheduling and pipeline automation.
Frequently Asked Questions
AiTechWorlds Team
✓ Verified WriterThe AiTechWorlds team is passionate about AI, technology, and education. We create high-quality, research-backed content to help you learn, grow, and succeed in the modern digital world.
Related Articles
The Python Libraries Every Developer Must Know in 2025
The essential Python libraries for 2025: from requests and pandas to FastAPI and LangChain — what each does, when to use it, and how to get started quickly.
Django vs Flask in 2025: Which Framework Should You Learn?
An honest Django vs Flask comparison for 2025 — which Python framework to learn first, when each excels, and why FastAPI has changed the equation.
FastAPI Tutorial: Building Your First REST API in 30 Minutes
A hands-on FastAPI tutorial for beginners: build a fully functional REST API in 30 minutes with CRUD endpoints, request validation, and automatic docs.
Jupyter Notebook Guide: The Data Scientist's Favorite Tool
A complete Jupyter Notebook guide for 2025: installation, essential shortcuts, best practices, and how data scientists use Jupyter for exploration, analysis, and sharing.