Test Architecture Patterns
Organize tests for maintainability, speed, and confidence.
Test Pyramid
┌─────────────┐
│ E2E │ Few, slow, high confidence
│ Browser │
├─────────────┤
│ Integration │ Moderate, real services
│ API/DB │
├─────────────┤
│ Unit │ Many, fast, isolated
│ Functions │
└─────────────┘
| Layer |
Count |
Speed |
Scope |
Tools |
| Unit |
70% |
<1ms |
Single function |
pytest, mock |
| Integration |
20% |
<1s |
Multiple components |
testcontainers, FastAPI TestClient |
| E2E |
10% |
<30s |
Full system |
Playwright, Selenium |
Directory Structure
project/
├── src/
│ └── myapp/
│ ├── models/
│ ├── services/
│ └── api/
├── tests/
│ ├── conftest.py # Shared fixtures
│ ├── unit/ # Fast, isolated tests
│ │ ├── conftest.py
│ │ ├── test_models.py
│ │ └── test_services.py
│ ├── integration/ # Real services tests
│ │ ├── conftest.py # DB, Redis fixtures
│ │ ├── test_api.py
│ │ └── test_repositories.py
│ ├── e2e/ # End-to-end tests
│ │ ├── conftest.py
│ │ └── test_user_flows.py
│ └── fixtures/ # Shared test data
│ └── users.json
└── pytest.ini
pytest Configuration
# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_functions = test_*
python_classes = Test*
# Markers for test categories
markers =
unit: Unit tests (fast, isolated)
integration: Integration tests (requires services)
e2e: End-to-end tests (full system)
slow: Slow tests (>1s)
# Default options
addopts =
-ra # Show summary of all except passed
--strict-markers # Error on unknown markers
-q # Quiet mode
Test Isolation Strategies
1. Database Isolation with Transactions
@pytest.fixture
def db_session(engine):
"""Each test runs in a rolled-back transaction."""
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
2. Schema Isolation (Parallel Safe)
import uuid
@pytest.fixture(scope="session")
def test_schema(engine):
"""Create isolated schema for test session."""
schema_name = f"test_{uuid.uuid4().hex[:8]}"
with engine.connect() as conn:
conn.execute(f"CREATE SCHEMA {schema_name}")
conn.execute(f"SET search_path TO {schema_name}")
yield schema_name
with engine.connect() as conn:
conn.execute(f"DROP SCHEMA {schema_name} CASCADE")
3. Container Isolation
@pytest.fixture(scope="session")
def isolated_postgres():
"""Each test session gets fresh PostgreSQL."""
with PostgresContainer("postgres:15") as pg:
yield pg.get_connection_url()
conftest.py Patterns
Root conftest.py
# tests/conftest.py
import pytest
from typing import Generator
# Session-scoped fixtures
@pytest.fixture(scope="session")
def app():
"""Create application once per session."""
from myapp import create_app
return create_app(testing=True)
@pytest.fixture(scope="session")
def engine(app):
"""Database engine for session."""
return app.extensions["db"].engine
# Function-scoped (per-test)
@pytest.fixture
def client(app) -> Generator:
"""Test client per test."""
with app.test_client() as client:
yield client
Unit Test conftest.py
# tests/unit/conftest.py
import pytest
from unittest.mock import Mock
@pytest.fixture
def mock_db():
"""Mock database for unit tests."""
return Mock()
@pytest.fixture
def mock_redis():
"""Mock Redis for unit tests."""
return Mock()
@pytest.fixture(autouse=True)
def no_network(monkeypatch):
"""Prevent network calls in unit tests."""
import socket
monkeypatch.setattr(socket, "socket", Mock(side_effect=Exception("No network in unit tests!")))
Integration Test conftest.py
# tests/integration/conftest.py
import pytest
@pytest.fixture(scope="session")
def postgres_container():
"""PostgreSQL container for integration tests."""
from testcontainers.postgres import PostgresContainer
with PostgresContainer("postgres:15") as pg:
yield pg
@pytest.fixture
def db_session(postgres_container):
"""Database session with rollback."""
# Transaction rollback pattern
...
Test Markers and Selection
import pytest
# Mark tests by category
@pytest.mark.unit
def test_calculate_total():
assert calculate_total([1, 2, 3]) == 6
@pytest.mark.integration
def test_save_to_database(db_session):
user = User(name="Test")
db_session.add(user)
db_session.commit()
assert user.id is not None
@pytest.mark.e2e
def test_user_signup_flow(browser):
browser.goto("/signup")
browser.fill("email", "test@example.com")
browser.click("Submit")
assert browser.url == "/dashboard"
@pytest.mark.slow
def test_data_migration():
migrate_all_records() # Takes 30 seconds
# Run specific categories
pytest -m unit # Only unit tests
pytest -m integration # Only integration tests
pytest -m "not slow" # Exclude slow tests
pytest -m "unit or integration" # Both
Parallel Testing
# pytest.ini
[pytest]
# Safe for parallel execution
addopts = -n auto # Use pytest-xdist
# conftest.py - ensure isolation
@pytest.fixture(scope="session")
def worker_id(request):
"""Get unique worker ID for parallel runs."""
if hasattr(request.config, "workerinput"):
return request.config.workerinput["workerid"]
return "master"
@pytest.fixture(scope="session")
def db_name(worker_id):
"""Unique database per worker."""
return f"testdb_{worker_id}"
Test Naming Conventions
# Pattern: test_<unit>_<condition>_<expected>
def test_user_creation_with_valid_data_succeeds():
pass
def test_user_creation_with_missing_email_raises_validation_error():
pass
def test_calculate_total_with_empty_list_returns_zero():
pass
def test_api_users_get_without_auth_returns_401():
pass
# Or use classes for grouping
class TestUserCreation:
def test_with_valid_data_succeeds(self):
pass
def test_with_missing_email_raises_validation_error(self):
pass
def test_with_duplicate_email_raises_conflict_error(self):
pass
Fixture Organization
# tests/fixtures/factories.py
import factory
from faker import Faker
fake = Faker()
class UserFactory(factory.Factory):
class Meta:
model = User
name = factory.LazyAttribute(lambda _: fake.name())
email = factory.LazyAttribute(lambda _: fake.email())
class OrderFactory(factory.Factory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
total = factory.LazyAttribute(lambda _: fake.pydecimal(min_value=1, max_value=1000))
# tests/conftest.py
from tests.fixtures.factories import UserFactory, OrderFactory
@pytest.fixture
def user():
return UserFactory()
@pytest.fixture
def order(user):
return OrderFactory(user=user)
Performance Testing
# pip install pytest-benchmark
def test_sort_performance(benchmark):
"""Benchmark sorting algorithm."""
data = list(range(10000, 0, -1))
result = benchmark(sort, data)
assert result == sorted(data)
# pip install pytest-timeout
@pytest.mark.timeout(5) # Fail if takes >5 seconds
def test_with_timeout():
slow_operation()
# Track memory
# pip install pytest-memray
@pytest.mark.limit_memory("100 MB")
def test_memory_usage():
large_operation()
Quick Reference
| Pattern |
When to Use |
| Transaction rollback |
Database tests, fast isolation |
| TestContainers |
Real service behavior needed |
| Schema isolation |
Parallel test execution |
| Factory fixtures |
Complex test data |
| Markers |
Categorize and filter tests |
| conftest layers |
Scope fixtures appropriately |
| Command |
Purpose |
pytest -m unit |
Run unit tests only |
pytest -n auto |
Parallel execution |
pytest --lf |
Last failed only |
pytest -x |
Stop on first failure |
pytest --cov=src |
Coverage report |