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.
📋 Table of Contents
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%.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment