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.
Get more content like this on Telegram!
Daily AI tips, notes & resources — 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
assertinstead ofself.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_*.pyor*_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:
- Write a failing test (it fails because the feature doesn't exist yet)
- Write the minimal code to make the test pass
- 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.
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!
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
Python Async Programming Guide 2026 — asyncio, aiohttp & Concurrency
Master async programming in Python with asyncio. Learn concurrent programming, aiohttp for async HTTP, async database operations, and build high-performance Python applications.
Python OOP Complete Guide 2026 — Object-Oriented Programming Mastery
Master Python object-oriented programming from basics to advanced. Classes, inheritance, polymorphism, SOLID principles, dataclasses — everything you need to write professional Python.
Python Error Handling & Debugging 2026 — Write Bulletproof Code
Master Python error handling and debugging techniques. Learn try/except, custom exceptions, logging, pdb, and professional debugging strategies to write robust Python code.
Python Decorators and Generators — Advanced Python Made Simple 2026
Master Python decorators and generators — two of Python's most powerful features. Clear explanations, real-world examples, and practical patterns you'll actually use.