🌐 Detecting your location…
📢 Advertisement — Configure AdSense in Appearance → Customize → AdSense Settings

Python Testing Best Practices 2026: Hypothesis, Mocking and Coverage

⏱️5 min read  ·  1,005 words

Writing good tests is a senior developer skill. Beyond basic pytest setup, production Python testing in 2026 requires understanding test architecture, fixtures, mocking strategies, property-based testing, and CI integration. This guide covers advanced testing patterns used in professional Python projects.

Test Architecture: The Testing Pyramid

Testing Pyramid:

         /   E2E   \       - Few, slow, expensive
        /  Integration \   - Some, moderate
       /  Unit Tests    \  - Many, fast, cheap

Rule: 70% unit, 20% integration, 10% E2E

Good test properties:
- Fast: unit tests < 1ms each
- Independent: no shared state between tests
- Repeatable: same result every run
- Self-validating: clear pass/fail
- Timely: written alongside/before code

Advanced Fixtures

import pytest
import asyncio
from unittest.mock import MagicMock, AsyncMock, patch
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine

# Fixture scope hierarchy
@pytest.fixture(scope="session")
def event_loop():
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()

@pytest.fixture(scope="session")
async def db_engine():
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    await create_all_tables(engine)
    yield engine
    await engine.dispose()

@pytest.fixture
async def db(db_engine):
    async with AsyncSession(db_engine) as session:
        yield session
        await session.rollback()  # clean state after each test

@pytest.fixture
def mock_email_service():
    with patch("app.services.email.EmailService.send") as mock:
        mock.return_value = True
        yield mock

# Factory fixture pattern
@pytest.fixture
def make_user(db):
    async def _factory(email="test@example.com", role="user", **kwargs):
        user = User(email=email, role=role, **kwargs)
        db.add(user)
        await db.commit()
        return user
    return _factory

# Usage
async def test_user_creation(db, make_user, mock_email_service):
    user = await make_user(email="alice@example.com")
    assert user.id is not None
    mock_email_service.assert_called_once_with(
        to="alice@example.com",
        subject="Welcome!"
    )

Property-Based Testing with Hypothesis

pip install hypothesis

from hypothesis import given, settings, strategies as st
from hypothesis.extra.pandas import data_frames, column

# Test with arbitrary inputs
@given(st.integers(), st.integers())
def test_add_commutative(a: int, b: int):
    assert add(a, b) == add(b, a)

@given(st.lists(st.integers(), min_size=1))
def test_sort_preserves_length(lst: list[int]):
    sorted_lst = sorted(lst)
    assert len(sorted_lst) == len(lst)

@given(st.text(min_size=1, max_size=100))
def test_hash_always_returns_string(text: str):
    result = hash_password(text)
    assert isinstance(result, str)
    assert len(result) > 0

# Complex data generation
@given(
    st.builds(
        CreateUserRequest,
        email=st.emails(),
        name=st.text(min_size=2, max_size=50).filter(str.strip),
        password=st.text(min_size=8, max_size=128),
    )
)
@settings(max_examples=50)
def test_user_creation_accepts_valid_data(request: CreateUserRequest):
    user = create_user(request)
    assert user.email == request.email.lower()

# Stateful testing
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant

class QueueMachine(RuleBasedStateMachine):
    def __init__(self):
        super().__init__()
        self.model = []
        self.implementation = Queue()

    @rule(value=st.integers())
    def enqueue(self, value: int):
        self.model.append(value)
        self.implementation.enqueue(value)

    @rule()
    def dequeue(self):
        if self.model:
            expected = self.model.pop(0)
            actual = self.implementation.dequeue()
            assert actual == expected

    @invariant()
    def sizes_match(self):
        assert len(self.model) == self.implementation.size()

Mocking Best Practices

from unittest.mock import MagicMock, AsyncMock, patch, call
import pytest

# Mock at the right level — where it's USED, not where it's defined
# If service.py imports: from utils import send_email
# Mock: "service.send_email" not "utils.send_email"

def test_order_sends_confirmation(mocker):
    # Mock async method
    mock_send = mocker.patch("app.services.order.send_email", new=AsyncMock())
    mock_send.return_value = True

    order = asyncio.run(create_order(user_id=1, items=[...]))

    mock_send.assert_called_once()
    call_kwargs = mock_send.call_args.kwargs
    assert call_kwargs["to"] == "alice@example.com"
    assert "order confirmation" in call_kwargs["subject"].lower()

# Mock with side effects
def test_retry_on_network_error(mocker):
    mock_get = mocker.patch("requests.get")
    mock_get.side_effect = [
        ConnectionError("Network error"),  # first call fails
        ConnectionError("Network error"),  # second call fails
        MagicMock(json=lambda: {"data": "success"}),  # third succeeds
    ]

    result = fetch_with_retry("https://api.example.com")
    assert result == {"data": "success"}
    assert mock_get.call_count == 3

# Context manager mock
def test_file_processing(mocker):
    mock_open = mocker.mock_open(read_data="line1
line2
line3")
    mocker.patch("builtins.open", mock_open)

    result = count_lines("any_file.txt")
    assert result == 3

Integration Tests with Real Services

# Using pytest-docker for real database in CI
import pytest
import psycopg2
from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16") as pg:
        yield pg

@pytest.fixture(scope="session")
def db_url(postgres):
    return postgres.get_connection_url()

# Using respx to mock HTTP at socket level
import respx
import httpx

@pytest.mark.asyncio
async def test_external_api_integration():
    with respx.mock:
        respx.get("https://api.github.com/users/alice").mock(
            return_value=httpx.Response(200, json={"login": "alice", "followers": 1000})
        )
        result = await fetch_github_user("alice")
        assert result["followers"] == 1000

Test Coverage and Quality Metrics

# pytest.ini or pyproject.toml
# [tool.pytest.ini_options]
# addopts = "--cov=app --cov-report=html --cov-report=term-missing --cov-fail-under=80"

# Run with coverage
pytest --cov=app --cov-report=html

# Mutation testing (verify your tests actually catch bugs)
pip install mutmut
mutmut run --paths-to-mutate app/services/
mutmut results  # shows surviving mutants (untested code paths)

# Performance benchmarks
pip install pytest-benchmark

@pytest.mark.benchmark
def test_encryption_performance(benchmark):
    result = benchmark(encrypt_password, "test_password")
    assert len(result) > 0  # benchmark.stats shows timing

Python testing best practices in 2026: write tests alongside code, use factory fixtures for test data, mock at the boundary not inside the implementation, use property-based testing for data validation, and measure mutation score to verify tests actually catch bugs. 80% coverage is the floor, not the ceiling — high-value paths should be 100%.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা