Pytest: Complete Testing Guide for Python (2026)

Pytest is the de-facto testing framework for Python — it beats unittest with simpler syntax, powerful fixtures, parametrize, and a rich plugin ecosystem. This guide covers everything from basic test discovery to async testing, API mocking with respx, coverage reporting and CI integration.

1. Setup and Discovery

pip install pytest pytest-asyncio pytest-cov httpx

Pytest discovers tests by finding files matching test_*.py or *_test.py and functions/methods starting with test_. Run:

pytest                          # run all tests
pytest tests/                   # specific directory
pytest tests/test_orders.py     # specific file
pytest -k "test_create"         # filter by name
pytest -v                       # verbose output
pytest -x                       # stop on first failure
pytest --tb=short               # shorter tracebacks

2. Fixtures

Fixtures provide reusable setup/teardown for tests. Scope controls how often they run:

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from myapp.database import Base
from myapp.models import User

@pytest.fixture(scope="session")
def db_engine():
    """Create test database once per test session."""
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    yield engine
    Base.metadata.drop_all(engine)

@pytest.fixture(scope="function")
def db_session(db_engine):
    """Provide a clean DB session for each test — rolls back after."""
    connection = db_engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()
    yield session
    session.close()
    transaction.rollback()
    connection.close()

@pytest.fixture
def sample_user(db_session):
    """Factory fixture — creates a test user."""
    user = User(email="test@example.com", name="Test User")
    db_session.add(user)
    db_session.commit()
    return user

def test_user_email(sample_user):
    assert sample_user.email == "test@example.com"
Pro Tip: Use scope="session" for expensive setup like database connections or Docker containers. Use scope="function" (default) for per-test isolation like rolling back database transactions.

3. Parametrize

Run the same test with multiple input/output combinations:

import pytest
from myapp.validators import validate_email, validate_password

@pytest.mark.parametrize("email,expected", [
    ("user@example.com", True),
    ("invalid-email", False),
    ("user@", False),
    ("@example.com", False),
    ("user@example.co.uk", True),
])
def test_email_validation(email, expected):
    assert validate_email(email) == expected

@pytest.mark.parametrize("password,expected_errors", [
    ("short", ["too_short"]),
    ("nouppercase1!", ["no_uppercase"]),
    ("NoSpecialChar1", ["no_special_char"]),
    ("Valid1Password!", []),
])
def test_password_validation(password, expected_errors):
    errors = validate_password(password)
    assert errors == expected_errors

4. Mocking with unittest.mock

from unittest.mock import patch, MagicMock, AsyncMock
from myapp.services import OrderService
from myapp.external import PaymentGateway

def test_create_order_calls_payment_gateway():
    with patch.object(PaymentGateway, 'charge') as mock_charge:
        mock_charge.return_value = {"status": "success", "transaction_id": "txn_123"}
        service = OrderService()
        result = service.create_order(user_id=1, amount=99.99)
        mock_charge.assert_called_once_with(amount=99.99, currency="USD")
        assert result["transaction_id"] == "txn_123"

def test_create_order_handles_payment_failure():
    with patch.object(PaymentGateway, 'charge') as mock_charge:
        mock_charge.side_effect = Exception("Card declined")
        service = OrderService()
        with pytest.raises(Exception, match="Card declined"):
            service.create_order(user_id=1, amount=99.99)

# Patching module-level imports
@patch("myapp.services.send_email")
@patch("myapp.services.PaymentGateway")
def test_order_sends_confirmation(MockPaymentGateway, mock_send_email):
    MockPaymentGateway.return_value.charge.return_value = {"status": "success"}
    service = OrderService()
    service.create_order(user_id=1, amount=50.00)
    mock_send_email.assert_called_once()

5. Async Testing with pytest-asyncio

import pytest
import pytest_asyncio
from myapp.services import AsyncOrderService

# Configure asyncio mode in pytest.ini or pyproject.toml:
# [tool.pytest.ini_options]
# asyncio_mode = "auto"

@pytest.mark.asyncio
async def test_async_order_creation():
    service = AsyncOrderService()
    result = await service.create_order(user_id=1, amount=49.99)
    assert result["status"] == "created"

@pytest_asyncio.fixture
async def async_client():
    """Async fixture for async service client."""
    client = AsyncOrderService()
    await client.connect()
    yield client
    await client.disconnect()

@pytest.mark.asyncio
async def test_order_list(async_client):
    orders = await async_client.list_orders(user_id=1)
    assert isinstance(orders, list)

6. Testing FastAPI with TestClient

import pytest
from fastapi.testclient import TestClient
from httpx import AsyncClient, ASGITransport
from myapp.main import app
from myapp.dependencies import get_db

@pytest.fixture
def client(db_session):
    """Override the DB dependency with test DB session."""
    def override_get_db():
        yield db_session
    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as test_client:
        yield test_client
    app.dependency_overrides.clear()

def test_create_user(client):
    response = client.post("/users", json={"email": "new@example.com", "name": "New User"})
    assert response.status_code == 201
    assert response.json()["email"] == "new@example.com"

def test_get_user_not_found(client):
    response = client.get("/users/99999")
    assert response.status_code == 404

# Async HTTP client for async endpoints
@pytest.mark.asyncio
async def test_async_endpoint():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        response = await ac.get("/health")
    assert response.status_code == 200

7. Coverage Reporting

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

# Set minimum coverage threshold (fails CI if below 80%)
pytest --cov=myapp --cov-fail-under=80

Configure in pyproject.toml:

[tool.coverage.run]
source = ["myapp"]
omit = ["*/migrations/*", "*/tests/*", "myapp/settings*.py"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]
show_missing = true

8. conftest.py Patterns

# tests/conftest.py — shared fixtures available to all tests

import pytest
from unittest.mock import patch

@pytest.fixture(autouse=True)
def disable_external_calls():
    """Auto-use fixture: prevent real HTTP calls in all tests."""
    with patch("httpx.Client.send") as mock:
        mock.side_effect = RuntimeError("Real HTTP calls not allowed in tests!")
        yield mock

@pytest.fixture
def mock_settings(monkeypatch):
    """Override app settings for tests."""
    monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
    monkeypatch.setenv("SECRET_KEY", "test-secret-key")
    monkeypatch.setenv("DEBUG", "true")

Frequently Asked Questions

What is the difference between @pytest.fixture and setUp/tearDown?

Pytest fixtures are more flexible — they use dependency injection (just name the fixture in the test parameter), support multiple scopes (function/class/module/session), and can yield for teardown. unittest's setUp/tearDown only run at class level and have no scope control.

How do I run only failed tests?

Use pytest --lf (last-failed) to run only tests that failed in the last run, or pytest --ff (failed-first) to run failed tests first then the rest. This massively speeds up TDD cycles.

How do I skip a test conditionally?

Use @pytest.mark.skip(reason="...") to always skip, or @pytest.mark.skipif(condition, reason="...") for conditional skipping — e.g., skipif(sys.platform == "win32", reason="Unix only").

What is monkeypatch and when should I use it over unittest.mock?

monkeypatch is a pytest fixture for temporarily modifying attributes, environment variables, and sys.path. It automatically reverses all changes after the test. Use it for simple attribute replacements and env vars. Use unittest.mock for complex mocking with assertions, side_effect, and return_value control.