Technical

Python Test Organization

Pattern for organizing Python tests by type (unit/integration/e2e) with shared fixtures.

Production

Problem

Python test suites grow organically and become difficult to maintain, navigate, and scale. Common issues include:

  • Mixed test types: Unit tests alongside integration tests with no clear separation
  • Duplicated test data: Same fixtures hardcoded across multiple test files
  • Flat structure: All tests in root directory making navigation difficult
  • Poor discoverability: Hard to find tests for specific components or scenarios
  • Inconsistent naming: No convention for test organization or grouping
  • Slow test runs: No way to run only fast unit tests vs slow integration/E2E tests

Context

Use this pattern when:

  • Building Python applications or libraries with pytest
  • Test suite has grown beyond 10-15 test files
  • Multiple test types exist (unit, integration, E2E)
  • Test data is duplicated across files
  • Team members struggle to find relevant tests

Don't use this pattern when:

  • Project has <5 test files (flat structure is fine)
  • All tests are the same type (e.g., only unit tests)
  • Using a different test framework (adapt pattern accordingly)

Solution

Organize tests by type (unit/integration/e2e) with shared fixtures for reusable test data.

Three-Layer Test Architecture

tests/
├── unit/           # Fast, isolated component tests
├── integration/    # Multi-component interaction tests
├── e2e/           # Full system end-to-end tests
├── fixtures/      # Reusable test data generators
└── conftest.py    # Shared pytest fixtures

Test Type Definitions

TypeScopeSpeedDependenciesExample
UnitSingle function/class<10msNone (mocked)Test to_prompt() method
IntegrationMultiple components10ms-1sReal dependenciesTest model + formatter + serializer
E2EComplete system>1sAll external systemsTest API endpoint → database → response

Structure

Directory Layout

tests/
├── unit/                              # Unit tests
│   ├── __init__.py
│   ├── test_models.py                 # Model validation tests
│   ├── test_services.py               # Service logic tests
│   └── test_utils.py                  # Utility function tests
│
├── integration/                       # Integration tests
│   ├── __init__.py
│   ├── test_workflow_scenarios.py     # Complete workflows
│   └── test_component_integration.py  # Multi-component tests
│
├── e2e/                               # End-to-end tests
│   ├── __init__.py
│   ├── test_api_flows.py              # Complete API scenarios
│   └── test_user_journeys.py          # User-facing workflows
│
├── fixtures/                          # Reusable test data
│   ├── __init__.py
│   ├── models.py                      # Model fixtures
│   ├── api_responses.py               # API response fixtures
│   └── database_records.py            # Database fixture data
│
├── conftest.py                        # Shared pytest fixtures
└── README.md                          # Test documentation

File Naming Conventions

  • Test files: test_<component_name>.py
  • Test classes: Test<Behavior> (group related tests)
  • Test functions: test_<specific_behavior>
  • Fixture files: <domain>.py (no test_ prefix)
  • Fixture functions: <descriptive_name>() (returns dict or callable)

Implementation

Step 1: Create Directory Structure

mkdir -p tests/{unit,integration,e2e,fixtures}
touch tests/{unit,integration,e2e,fixtures}/__init__.py
touch tests/conftest.py

Step 2: Create Shared Fixtures (conftest.py)

"""Shared pytest fixtures available to all tests."""

import pytest
from myapp.models import User, Document

@pytest.fixture
def simple_user():
    """Quick User fixture for unit tests."""
    return User(
        id="user-123",
        email="test@example.com",
        role="user"
    )

@pytest.fixture
def admin_user():
    """Admin User fixture for permission tests."""
    return User(
        id="admin-456",
        email="admin@example.com",
        role="admin"
    )

@pytest.fixture
def test_database(tmp_path):
    """Temporary database for integration tests."""
    db_path = tmp_path / "test.db"
    # Setup database
    yield db_path
    # Cleanup happens automatically with tmp_path

Step 3: Create Reusable Data Fixtures (fixtures/)

# fixtures/users.py
"""Reusable user test data generators."""

def basic_user_data():
    """Simple user for basic tests."""
    return {
        "id": "user-001",
        "email": "user@example.com",
        "name": "Test User",
        "role": "user"
    }

def admin_user_data():
    """Admin user with full permissions."""
    return {
        "id": "admin-001",
        "email": "admin@example.com",
        "name": "Admin User",
        "role": "admin",
        "permissions": ["read", "write", "delete"]
    }

def user_with_profile():
    """User with complete profile data."""
    return {
        **basic_user_data(),
        "profile": {
            "bio": "Test bio",
            "avatar_url": "https://example.com/avatar.jpg",
            "preferences": {"theme": "dark"}
        }
    }

Step 4: Write Unit Tests (unit/)

# unit/test_user_model.py
"""Unit tests for User model."""

import pytest
from myapp.models import User

class TestUserModel:
    """Tests for User model validation."""

    def test_create_user(self, simple_user):
        """Test creating a basic user."""
        assert simple_user.id == "user-123"
        assert simple_user.email == "test@example.com"

    def test_user_validation(self):
        """Test user validation rules."""
        with pytest.raises(ValueError, match="Invalid email"):
            User(id="u1", email="invalid")

class TestUserMethods:
    """Tests for User model methods."""

    def test_has_permission(self, admin_user):
        """Test permission checking."""
        assert admin_user.has_permission("write") is True

    def test_to_dict(self, simple_user):
        """Test serialization to dictionary."""
        data = simple_user.to_dict()
        assert data["id"] == "user-123"
        assert "email" in data

Step 5: Write Integration Tests (integration/)

# integration/test_user_workflows.py
"""Integration tests for user workflows."""

import pytest
from myapp.models import User
from myapp.services import UserService
from tests.fixtures import users

class TestUserRegistration:
    """Integration tests for user registration workflow."""

    def test_complete_registration_flow(self, test_database):
        """Test complete user registration from start to finish."""
        # Given: User data
        user_data = users.basic_user_data()

        # When: User registers
        service = UserService(database=test_database)
        user = service.register_user(user_data)

        # Then: User is created and can be retrieved
        assert user.id is not None
        retrieved = service.get_user(user.id)
        assert retrieved.email == user_data["email"]

    def test_duplicate_email_handling(self, test_database):
        """Test that duplicate emails are rejected."""
        service = UserService(database=test_database)
        user_data = users.basic_user_data()

        # First registration succeeds
        service.register_user(user_data)

        # Second registration fails
        with pytest.raises(ValueError, match="Email already exists"):
            service.register_user(user_data)

Step 6: Write E2E Tests (e2e/)

# e2e/test_api_user_flows.py
"""End-to-end tests for user API flows."""

import pytest
import requests
from tests.fixtures import users

@pytest.fixture
def api_client(test_server):
    """API client connected to test server."""
    return requests.Session()

class TestUserAPIFlow:
    """E2E tests for user API endpoints."""

    def test_full_user_lifecycle(self, api_client, test_server):
        """Test complete user lifecycle via API."""
        base_url = test_server.url

        # 1. Register user
        user_data = users.basic_user_data()
        response = api_client.post(f"{base_url}/users", json=user_data)
        assert response.status_code == 201
        user_id = response.json()["id"]

        # 2. Login
        response = api_client.post(f"{base_url}/auth/login", json={
            "email": user_data["email"],
            "password": "password123"
        })
        assert response.status_code == 200
        token = response.json()["token"]

        # 3. Get user profile
        headers = {"Authorization": f"Bearer {token}"}
        response = api_client.get(f"{base_url}/users/{user_id}", headers=headers)
        assert response.status_code == 200
        assert response.json()["email"] == user_data["email"]

        # 4. Update profile
        response = api_client.patch(
            f"{base_url}/users/{user_id}",
            headers=headers,
            json={"name": "Updated Name"}
        )
        assert response.status_code == 200

        # 5. Delete user
        response = api_client.delete(f"{base_url}/users/{user_id}", headers=headers)
        assert response.status_code == 204

        # 6. Verify deletion
        response = api_client.get(f"{base_url}/users/{user_id}", headers=headers)
        assert response.status_code == 404

Step 7: Document Tests (README.md)

# Tests

## Structure

- `unit/` - Fast isolated tests (25 tests, <1s)
- `integration/` - Multi-component tests (12 tests, ~5s)
- `e2e/` - Full system tests (8 tests, ~30s)
- `fixtures/` - Reusable test data

## Running Tests

```bash
# All tests
pytest

# Only unit tests (fast)
pytest tests/unit

# Only integration tests
pytest tests/integration

# Only E2E tests (slow)
pytest tests/e2e

# Specific test file
pytest tests/unit/test_user_model.py

# Specific test
pytest tests/unit/test_user_model.py::TestUserModel::test_create_user

## Applies Principles

- **Separation of Concerns**: Different test types in different directories
- **DRY (Don't Repeat Yourself)**: Shared fixtures eliminate duplication
- **Single Responsibility**: Each test tests one specific behavior
- **Clear Structure**: Tests mirror application structure
- **Fast Feedback**: Can run fast unit tests independently

## Consequences

### Benefits

✅ **Clear organization**: Easy to find tests for specific components
✅ **Fast test runs**: Can run only unit tests for quick feedback
✅ **Reduced duplication**: Shared fixtures used across tests
✅ **Better scalability**: Structure supports growing test suite
✅ **Improved maintainability**: Clear conventions for adding tests
✅ **Parallel execution**: Different test types can run in parallel in CI

### Trade-offs

⚠️ **More structure**: Requires more files and directories
⚠️ **Learning curve**: Team needs to understand test type distinctions
⚠️ **Initial setup**: Takes time to set up structure and fixtures
⚠️ **Fixture management**: Need to keep fixtures organized and documented

### When It's Worth It

Worth the investment when:
- Test suite has >15 test files
- Team has >2 developers
- Project will be maintained >6 months
- Multiple test types exist

Not worth it when:
- Small project with <10 tests
- Solo developer project
- Prototype or POC

## Examples

### Real Implementation: ontopix/schemas

libs/python/tests/ ├── unit/ # 25 unit tests │ ├── test_customer_interaction.py # 18 tests, organized by behavior │ ├── test_audit_criteria.py # 6 tests │ └── test_audit_result.py # 3 tests │ ├── integration/ # 7 integration tests │ ├── test_customer_interaction_scenarios.py │ └── test_format_combinations.py │ ├── fixtures/ │ ├── customer_interactions.py # 4 reusable scenarios │ └── audit_criteria.py │ └── conftest.py # Shared pytest fixtures

Run unit tests only (fast feedback)

pytest tests/unit -v # 0.03s

Run all tests

pytest tests -v # 0.04s


**Outcome**: 32 tests organized clearly, easy to navigate, fast to run.

## Variations

### Variation 1: By Component (Alternative)

tests/ ├── users/ │ ├── unit/ │ ├── integration/ │ └── fixtures/ ├── documents/ │ ├── unit/ │ ├── integration/ │ └── fixtures/ └── conftest.py


**When to use**: When components are very independent and large

### Variation 2: With Performance Tests

tests/ ├── unit/ ├── integration/ ├── e2e/ ├── performance/ # Performance/load tests ├── fixtures/ └── conftest.py


**When to use**: When performance testing is critical

### Variation 3: Contract Tests

tests/ ├── unit/ ├── integration/ ├── e2e/ ├── contract/ # API contract tests ├── fixtures/ └── conftest.py


**When to use**: Microservices with API contracts

## Related Patterns

- [Repository Structure](../organizational/repository-structure.md) - Overall project layout
- [Taskfile Contract](../organizational/taskfile-contract.md) - Standardized `task test` command
- [Python Module Naming](./python-module-naming.md) - Module organization patterns

## References

### Internal
- ADR: Test Organization Strategy (schemas repository)
- Implementation: `ontopix/schemas/libs/python/tests/`

### External
- [Pytest Documentation](https://docs.pytest.org/)
- [Test Pyramid Pattern](https://martinfowler.com/articles/practical-test-pyramid.html)
- [Arrange-Act-Assert Pattern](https://automationpanda.com/2020/07/07/arrange-act-assert-a-pattern-for-writing-good-tests/)

---

**Status**: ✅ Active
**Created**: 2026-01-27
**Last Updated**: 2026-01-27
**Applies to**: All Python projects with pytest