Skip to content

Test Architecture

Good test architecture keeps your test code organized, maintainable, and scalable.


Project Structure

project/
├── src/
│   └── app/
│       ├── __init__.py
│       ├── models.py
│       └── services.py
├── tests/
│   ├── conftest.py
│   ├── unit/
│   │   ├── conftest.py
│   │   ├── test_models.py
│   │   └── test_services.py
│   ├── integration/
│   │   ├── conftest.py
│   │   └── test_database.py
│   └── e2e/
│       ├── conftest.py
│       └── test_user_flow.py
├── pyproject.toml
└── uv.lock

Key Rules

  • Separate tests by level (unit, integration, e2e)
  • Mirror the source structure in test folders
  • Use conftest.py at each level for shared fixtures
  • Keep test files focused — one test file per source module

Naming Conventions

Item Convention Example
Test file test_<module>.py test_user.py
Test function test_<what>_<condition> test_login_with_valid_credentials
Test class Test<Feature> TestUserCreation
Fixture <noun> or <adjective>_<noun> sample_user, auth_headers
conftest conftest.py Always this name

Test Configuration

pyproject.toml

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
python_classes = ["Test*"]
addopts = "-v --tb=short --strict-markers"
markers = [
    "unit: unit tests",
    "integration: integration tests",
    "e2e: end-to-end tests",
    "slow: slow tests",
]

Design Patterns for Tests

Page Object Model (POM)

Used in UI tests. Each page of the app has a class:

class LoginPage:
    def __init__(self, page) -> None:
        self.page = page
        self.url = "/login"

    def navigate(self) -> None:
        self.page.goto(self.url)

    def login(self, username: str, password: str) -> None:
        self.page.fill("#username", username)
        self.page.fill("#password", password)
        self.page.click("#submit")

def test_successful_login(page):
    login_page = LoginPage(page)
    login_page.navigate()
    login_page.login("alice", "password123")
    assert page.url.endswith("/dashboard")

API Client Abstraction

Wrap API calls in a client class:

class UserApiClient:
    def __init__(self, base_url: str, headers: dict) -> None:
        self.base_url = base_url
        self.headers = headers

    def create_user(self, name: str, email: str) -> dict:
        response = requests.post(
            f"{self.base_url}/users",
            json={"name": name, "email": email},
            headers=self.headers,
        )
        response.raise_for_status()
        return response.json()

    def get_user(self, user_id: int) -> dict:
        response = requests.get(
            f"{self.base_url}/users/{user_id}",
            headers=self.headers,
        )
        response.raise_for_status()
        return response.json()

Singleton Pattern

Use Singleton when you need exactly one instance of something across all tests (e.g. a shared browser session or config). Use carefully — it can cause test pollution.

class ConfigManager:
    _instance: "ConfigManager | None" = None

    def __new__(cls) -> "ConfigManager":
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._config = {}
        return cls._instance

    def set(self, key: str, value: str) -> None:
        self._config[key] = value

    def get(self, key: str) -> str:
        return self._config.get(key, "")

Use Singleton carefully in tests

Singleton holds state between tests. Always clean up after each test to avoid test pollution.


Factory Pattern

Create test data with factories:

class UserFactory:
    _counter: int = 0

    @classmethod
    def create(
        cls,
        name: str | None = None,
        email: str | None = None,
    ) -> dict:
        cls._counter += 1
        return {
            "name": name or f"User_{cls._counter}",
            "email": email or f"user_{cls._counter}@test.com",
        }

def test_user_list():
    users = [UserFactory.create() for _ in range(5)]
    assert len(users) == 5
    assert all("@test.com" in u["email"] for u in users)

Fixtures Organization

Layered conftest.py

tests/
├── conftest.py          # shared: base_url, auth
├── unit/
│   └── conftest.py      # unit: mocks, stubs
├── integration/
│   └── conftest.py      # integration: db, services
└── e2e/
    └── conftest.py      # e2e: browser, pages

Best Practices

  • Separate tests by level (unit / integration / e2e)
  • Use Page Object Model for UI tests
  • Use API client classes for API tests
  • Use factories to create test data
  • Keep test logic simple — no loops or conditions in tests
  • Avoid shared state between tests
  • Avoid test duplication — use parametrize and fixtures
  • Keep test data close to tests — in fixtures or factories