Python Testing with Pytest: Write Tests That Actually Catch Bugs
โก Quick Answer
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
Advertisement
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.
Pytest is Python's most-used testing framework: write a function starting with test_, assert what should be true, and run pytest to get pass/fail results in seconds. This tutorial teaches it from scratch with a focus on writing tests that actually find bugs โ not just tests that pass.
Setup
pip install pytest pytest-mockPytest 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.pyRun 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.0Run: 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 NoneTesting 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 TrueFixtures โ Reusable Setup
A pytest fixture is a reusable setup function โ think of it as prepping ingredients once in the kitchen instead of re-chopping onions for every single dish. Declare it once, inject it into any test that names it as a parameter.
# 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 == 0Fixtures 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/failParameterized 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. A mock is a stand-in object that pretends to be the real dependency โ like a crash-test dummy standing in for a driver, so you can test the impact without a hospital visit. Use mocks to control exactly what an external function returns.
# 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 == 404For 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=htmlCoverage shows which lines aren't tested. Aim for 80%+ coverage on business logic โ but coverage percentage measures execution, not correctness; a line can run inside a test and still have zero assertions checking its output.
Further Reading
- Python Automation: 20 Scripts That Will Save You Hours Every Week
- How I Learned Python in 3 Months and Got a Job: My Honest Story
- Async Python: Why Your Programs Are Slow and How to Fix Them
- Django vs Flask in 2025: Which Framework Should You Learn?
- FastAPI Tutorial: Building Your First REST API in 30 Minutes
- React vs Next.js vs Remix: Which Framework Should You Choose?
- Building a REST API with Node.js and Express for Beginners
- How to Deploy a React App to Vercel in 10 Minutes
Advertisement
๐ฌ DiscussionPowered by GitHub Discussions
Frequently Asked Questions
Problem Solver and Cloud Expert
Solves complex infrastructure challenges and architects reliable, scalable cloud deployments for every project. Shamshur Rahman keeps AiTechWorldsโ hosting and cloud infrastructure fast, resilient, and cost-efficient.
Not sure yet? Ask AI about this article
Get an instant, unbiased AI summary of โPython Testing with Pytest: Write Tests That Actually Catch Bugsโ.
Advertisement
Related Articles
The Python Libraries Every Developer Must Know in 2026
The essential Python libraries for 2026: 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 2026: Which Framework Should You Learn?
An honest Django vs Flask comparison for 2026 โ 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 2026: installation, essential shortcuts, best practices, and how data scientists use Jupyter for exploration, analysis, and sharing.