Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →

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.

A
AiTechWorlds Team
May 27, 2026 7 min read
📱

Get more content like this on Telegram!

Daily AI tips, notes & resources — free

Join 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.

Share this article:

Frequently Asked Questions

Tests catch bugs before they reach production, document expected behavior, enable confident refactoring (change code without fear of breaking things), and speed up debugging (a failing test pinpoints exactly what broke). Without tests, every code change requires manual verification of all affected functionality. With tests, you run a command and know in seconds if anything broke. The up-front investment in writing tests pays back quickly on any project you'll touch more than once.
A

AiTechWorlds Team

✓ Verified Writer

The 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

10K+ Members Growing Daily

Get Free AI Notes Daily

Join AiTechWorlds on Telegram and get daily AI tips, prompt engineering templates, coding resources, and exclusive content — 100% free!

📚 Free Study Notes🤖 AI Tips Daily⚡ Prompt Templates💻 Coding Resources
Join Free Channel

No spam. Leave anytime.

!