test-architecture.md 8.7 KB

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