AiTechWorlds

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.

Shamshur Rahman 2026-05-07 8 min read
Python code editor with script on monitor, python testing with pytest 2026  from beginner to

Python Testing with pytest 2026 Write Tests That Save Your Career

pytest is Python's most used testing framework it lets you verify your code works by writing small, automated check functions instead of manually re running the app after every change. Think of it as a pit crew doing a full inspection lap before every race, catching a loose bolt before it costs you the win.

Senior developers write tests, not because a process mandates it, but because they've been burned by untested code enough times to treat tests as insurance rather than overhead. Junior developers who skip that discipline tend to stay stuck fixing the same class of bug repeatedly.

This guide covers pytest from first assertion to professional patterns: 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:

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:

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

Level Speed Quantity Purpose
Unit tests Very fast Many (70%) Test individual functions
Integration tests Medium Some (20%) Test components together
End to end tests Slow Few (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.

Untested code accumulates risk silently; tests convert that risk into a fast, repeatable check you run before every deploy.

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

Further Reading

Frequently Asked Questions

Why should I write tests for my Python code?

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.

What is the difference between unit tests and integration tests?

Unit tests test a single function in isolation. Integration tests check that multiple parts work together correctly. Both are needed, unit tests are faster, integration tests catch interaction bugs.

How much test coverage should I aim for?

80-90% coverage is a good target for most projects. 100% is not always practical or worth the effort. Focus on covering all business logic and edge cases, not just achieving a number.

#python#pytest#testing#unit-tests#tdd#software-quality#coding