Python Testing with pytest 2026 — From Beginner to Pro Guide
⚡ Quick Answer
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
Advertisement
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:
- 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-covYour 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 failurePytest 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.0Classes 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) == expectedThis 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 errorMocking — 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.htmlOutput:
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 — PASSEDTDD 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
- The Python Libraries Every Developer Must Know in 2025
- How to Use Python for Data Science (Complete 2025 Roadmap)
- Python Pandas Tutorial 2026 — Data Analysis for Beginners to Pro
- Django vs FastAPI 2026 — Which Python Framework Should You Choose?
- Python for Machine Learning 2026 — Your First ML Project with scikit-learn
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 2026 — From Beginner to Pro Guide”.
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.