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

Python Testing with pytest 2026 — From Beginner to Pro Guide

Learn Python testing with pytest from scratch. Write unit tests, integration tests, use fixtures and mocks, measure coverage — everything a professional Python developer needs.

A
AiTechWorlds Team
May 7, 2026 8 min readUpdated May 15, 2026
📱

Get more content like this on Telegram!

Daily AI tips, notes & resources — free

Join Free →

Python Testing with pytest 2026 — Write Tests That Save Your Career

Here is the reality no one tells junior developers: the difference between a developer who gets promoted and one who gets stuck at junior level is often just one thing — testing discipline.

Senior developers write tests. Not because someone forces them to, but because they have been burned enough times by bugs in untested code to know that tests are not overhead — they are insurance.

This guide teaches you pytest from the very basics to professional patterns including fixtures, mocking, parametrize, and coverage reporting.


Why pytest Over unittest?

Python ships with unittest, but the community has largely moved to pytest for these reasons:

  • Simpler syntax: Use plain assert instead of self.assertEqual()
  • Better output: Colored, detailed failure messages
  • Fixtures: Powerful dependency injection (much better than setUp/tearDown)
  • Parametrize: Test many inputs with one test function
  • Plugins: Thousands of plugins for coverage, async, Django, FastAPI, etc.
pip install pytest pytest-cov

Your First Tests

# calculator.py
def add(a: float, b: float) -> float:
    return a + b

def subtract(a: float, b: float) -> float:
    return a - b

def multiply(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
# test_calculator.py
from calculator import add, subtract, multiply, divide
import pytest

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, -1) == -2

def test_add_floats():
    assert add(0.1, 0.2) == pytest.approx(0.3)

def test_subtract():
    assert subtract(10, 4) == 6

def test_multiply():
    assert multiply(3, 4) == 12

def test_divide():
    assert divide(10, 2) == 5.0

def test_divide_by_zero_raises_value_error():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(10, 0)

Run tests:

pytest                        # Run all tests
pytest test_calculator.py     # Run specific file
pytest -v                     # Verbose output (shows each test name)
pytest -k "add"               # Run only tests with "add" in the name
pytest --tb=short             # Short traceback on failure

Pytest Naming Rules

pytest discovers tests automatically by following these conventions:

  • Files must be named test_*.py or *_test.py
  • Functions must start with test_
  • Classes must start with Test (no __init__ needed)

Organizing Tests with Classes

class TestDivide:
    def test_normal_division(self):
        assert divide(10, 2) == 5.0

    def test_float_division(self):
        assert divide(1, 3) == pytest.approx(0.333, rel=1e-3)

    def test_division_by_zero(self):
        with pytest.raises(ValueError):
            divide(5, 0)

    def test_negative_division(self):
        assert divide(-10, 2) == -5.0

Classes group related tests and make the test file easier to navigate. No inheritance required — just grouping.


Parametrize — Test Many Inputs, One Test

import pytest
from calculator import add, divide

@pytest.mark.parametrize("a, b, expected", [
    (2, 3, 5),
    (-1, -1, -2),
    (0, 0, 0),
    (100, -50, 50),
    (0.5, 0.5, 1.0),
])
def test_add_parametrized(a, b, expected):
    assert add(a, b) == pytest.approx(expected)

@pytest.mark.parametrize("numerator, denominator, expected", [
    (10, 2, 5.0),
    (7, 2, 3.5),
    (-10, 5, -2.0),
    (0, 10, 0.0),
])
def test_divide_parametrized(numerator, denominator, expected):
    assert divide(numerator, denominator) == expected

This replaces 8 separate test functions with 2 parametrized ones. Same coverage, much less code.


Fixtures — Reusable Test Setup

Fixtures are functions that provide test dependencies. pytest injects them automatically based on parameter names.

# conftest.py (shared fixtures go here — available to all test files)
import pytest
from sqlmodel import Session, SQLModel, create_engine

@pytest.fixture
def sample_user():
    return {"id": 1, "name": "Alice", "email": "alice@example.com", "age": 30}

@pytest.fixture
def sample_users():
    return [
        {"id": 1, "name": "Alice", "email": "alice@example.com"},
        {"id": 2, "name": "Bob", "email": "bob@example.com"},
        {"id": 3, "name": "Charlie", "email": "charlie@example.com"},
    ]

@pytest.fixture
def db_session():
    """Create a fresh in-memory database for each test."""
    engine = create_engine("sqlite:///:memory:", echo=False)
    SQLModel.metadata.create_all(engine)
    with Session(engine) as session:
        yield session
    SQLModel.metadata.drop_all(engine)
# test_users.py
def test_user_has_required_fields(sample_user):
    assert "id" in sample_user
    assert "name" in sample_user
    assert "email" in sample_user

def test_user_list_length(sample_users):
    assert len(sample_users) == 3

def test_create_user_in_db(db_session):
    from models import User
    user = User(name="Alice", email="alice@example.com")
    db_session.add(user)
    db_session.commit()
    
    found = db_session.get(User, user.id)
    assert found.name == "Alice"

Fixture Scopes

@pytest.fixture(scope="session")   # Created once for all tests in the session
def expensive_resource():
    # Set up database connection, start server, etc.
    resource = create_expensive_thing()
    yield resource
    resource.cleanup()

@pytest.fixture(scope="module")    # Created once per test module (file)
def module_level_data():
    return load_large_dataset()

@pytest.fixture(scope="function")  # Default: fresh for each test function
def user_data():
    return {"name": "Test User"}

Testing APIs with FastAPI TestClient

# main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
tasks = {}
counter = 1

class Task(BaseModel):
    title: str
    completed: bool = False

@app.post("/tasks", status_code=201)
def create_task(task: Task):
    global counter
    new_task = {"id": counter, **task.model_dump()}
    tasks[counter] = new_task
    counter += 1
    return new_task

@app.get("/tasks/{task_id}")
def get_task(task_id: int):
    from fastapi import HTTPException
    if task_id not in tasks:
        raise HTTPException(status_code=404)
    return tasks[task_id]
# test_api.py
import pytest
from fastapi.testclient import TestClient
from main import app

@pytest.fixture
def client():
    with TestClient(app) as c:
        yield c

def test_create_task(client):
    response = client.post("/tasks", json={"title": "Buy groceries"})
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "Buy groceries"
    assert data["completed"] == False
    assert "id" in data

def test_get_existing_task(client):
    create_resp = client.post("/tasks", json={"title": "Test task"})
    task_id = create_resp.json()["id"]
    
    get_resp = client.get(f"/tasks/{task_id}")
    assert get_resp.status_code == 200
    assert get_resp.json()["title"] == "Test task"

def test_get_nonexistent_task(client):
    response = client.get("/tasks/99999")
    assert response.status_code == 404

def test_create_task_missing_title(client):
    response = client.post("/tasks", json={"completed": True})
    assert response.status_code == 422  # Validation error

Mocking — Isolate External Dependencies

Mocks replace real external services (APIs, databases, email servers) with fake versions during testing.

from unittest.mock import Mock, patch, MagicMock

# Mock an external API call
def get_weather(city: str) -> dict:
    import requests
    response = requests.get(f"https://api.weather.com/{city}")
    return response.json()

# Test it without hitting the real API
def test_get_weather(monkeypatch):
    mock_response = Mock()
    mock_response.json.return_value = {"city": "NYC", "temp": 22, "condition": "sunny"}
    
    with patch("requests.get", return_value=mock_response):
        result = get_weather("NYC")
    
    assert result["city"] == "NYC"
    assert result["temp"] == 22

# Using pytest monkeypatch (cleaner for simple cases)
def test_send_email(monkeypatch):
    sent_emails = []
    
    def mock_send(to, subject, body):
        sent_emails.append({"to": to, "subject": subject})
    
    monkeypatch.setattr("mymodule.send_email", mock_send)
    
    trigger_notification("user@example.com", "Welcome!")
    
    assert len(sent_emails) == 1
    assert sent_emails[0]["to"] == "user@example.com"

Code Coverage

Coverage tells you what percentage of your code is exercised by tests.

# Run tests with coverage
pytest --cov=myapp --cov-report=term-missing

# Generate HTML report (opens in browser)
pytest --cov=myapp --cov-report=html
open htmlcov/index.html

Output:

Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
myapp/calculator.py        12      1    92%   line 34
myapp/models.py            45      8    82%
myapp/utils.py             23      0   100%
-----------------------------------------------------
TOTAL                      80     9    89%

Lines marked "Missing" are code that no test exercises. Review them — some are worth testing, some are dead code to delete.


Test-Driven Development (TDD)

TDD is writing tests before writing the code:

  1. Write a failing test (it fails because the feature doesn't exist yet)
  2. Write the minimal code to make the test pass
  3. Refactor the code while keeping tests green
# Step 1: Write the test first
def test_validate_email():
    assert validate_email("user@example.com") == True
    assert validate_email("invalid-email") == False
    assert validate_email("") == False

# Step 2: Run it — it fails (ImportError or NameError)
# pytest test_validators.py — FAILED

# Step 3: Write the function
import re

def validate_email(email: str) -> bool:
    if not email:
        return False
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.match(pattern, email))

# Step 4: Run tests — they pass
# pytest test_validators.py — PASSED

TDD forces you to think about the interface before the implementation, resulting in cleaner, more focused code.


pytest Configuration

# pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py", "*_test.py"]
python_functions = ["test_*"]
addopts = "-v --tb=short --cov=src --cov-report=term-missing"
filterwarnings = ["ignore::DeprecationWarning"]

Now running pytest automatically runs with verbose output and coverage.


The Testing Pyramid

LevelSpeedQuantityPurpose
Unit testsVery fastMany (70%)Test individual functions
Integration testsMediumSome (20%)Test components together
End-to-end testsSlowFew (10%)Test full user workflows

Write mostly unit tests (fast, cheap, targeted), some integration tests (catch interaction bugs), and minimal E2E tests (slow, brittle, but verify real user flows).

For building the projects you will test, see the FastAPI tutorial and our Python beginners roadmap. Testing is most valuable when paired with proper dependency management — set that up first with the virtual environments guide.

Write your tests. Future you — debugging at 11 PM before a release — will be grateful.

pytest cheat sheets and testing templates available in the AiTechWorlds Telegram channel!

Share this article:

Frequently Asked Questions

Tests catch bugs before users do, make refactoring safe, document expected behavior, and give you confidence to deploy. Projects without tests accumulate bugs that are expensive to fix later.
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.

!