Python Test Organization
Pattern for organizing Python tests by type (unit/integration/e2e) with shared fixtures.
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
| Type | Scope | Speed | Dependencies | Example |
|---|---|---|---|---|
| Unit | Single function/class | <10ms | None (mocked) | Test to_prompt() method |
| Integration | Multiple components | 10ms-1s | Real dependencies | Test model + formatter + serializer |
| E2E | Complete system | >1s | All external systems | Test 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