This guide outlines the testing strategy, standards, and best practices for the Zyeta backend codebase. It serves as a blueprint for developers to ensure consistent quality and reliability through automated testing.

Overview

A robust testing approach is crucial for maintaining code quality and ensuring that our services function correctly after changes. Our testing strategy comprises multiple levels:

Test Pyramid

The test pyramid represents the ideal distribution of different test types:

This visualization demonstrates that:

  • Unit tests should form the majority of your test suite (fastest to run, easiest to write)
  • Integration tests should be fewer in number but cover critical component interactions
  • End-to-End tests should be the fewest but test complete user workflows

Test Directory Structure

Organize tests with a clear structure that mirrors the source code:

zyeta.backend/
├── src/
│   ├── models/
│   ├── services/
│   └── ...
└── tests/
    ├── unit/
    │   ├── models/
    │   └── services/
    ├── integration/
    │   └── services/
    ├── e2e/
    │   └── api/
    ├── conftest.py
    └── fixtures/
        ├── database.py
        ├── models.py
        └── services.py

Setting Up the Testing Environment

Prerequisites

The project uses pytest for running tests. Dependencies are managed through Poetry and include:

  • pytest
  • pytest-asyncio (for testing async code)
  • pytest-cov (for coverage reporting)

These are already included in pyproject.toml.

Test Configuration

Create a conftest.py file at the root of the tests directory to define shared fixtures:

import asyncio
import os
import pytest
from typing import AsyncGenerator, Generator

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

from src.database import Base, get_db
from src.config import get_settings

# Test database URL - use an isolated test database
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/test_zyeta"

@pytest.fixture(scope="session")
def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
    """Create an instance of the default event loop for each test session."""
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()

@pytest.fixture(scope="session")
async def db_engine():
    """Create a test database engine."""
    engine = create_async_engine(TEST_DATABASE_URL)
    
    # Create all tables
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
        await conn.run_sync(Base.metadata.create_all)
    
    yield engine
    
    # Clean up
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    
    await engine.dispose()

@pytest.fixture
async def db_session(db_engine) -> AsyncGenerator[AsyncSession, None]:
    """Create a test database session."""
    async_session = sessionmaker(
        db_engine, expire_on_commit=False, class_=AsyncSession
    )
    
    async with async_session() as session:
        # Begin transaction
        async with session.begin():
            yield session
            # Rollback transaction after test completes
            await session.rollback()

@pytest.fixture
async def override_get_db(db_session: AsyncSession):
    """Override the get_db dependency for testing."""
    async def _override_get_db():
        yield db_session
    
    return _override_get_db

Test Fixture Dependencies

Unit Tests

Unit tests focus on testing individual components in isolation.

Writing Unit Tests for Models

# tests/unit/models/test_agent_model.py
import pytest
from uuid import uuid4

from src.models import AgentModel

@pytest.mark.asyncio
async def test_agent_model_creation(db_session):
    """Test creating an AgentModel instance."""
    # Arrange
    agent_data = {
        "organization_id": uuid4(),
        "user_id": uuid4(),
        "name": "Test Agent",
        "description": "Test agent description",
        "model_id": uuid4(),
        "is_active": True,
        "settings": {"key": "value"}
    }
    
    # Act
    agent = AgentModel(**agent_data)
    db_session.add(agent)
    await db_session.commit()
    await db_session.refresh(agent)
    
    # Assert
    assert agent.id is not None
    assert agent.name == "Test Agent"
    assert agent.is_active is True

Unit Test Flow

Writing Unit Tests for Services

# tests/unit/services/test_agent_service.py
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4

from src.services.agents.service import AgentService
from src.services.agents.schema import AgentCreate

@pytest.mark.asyncio
async def test_create_agent(db_session):
    """Test creating an agent with AgentService."""
    # Arrange
    org_id = uuid4()
    user_mock = {"id": uuid4(), "org_id": org_id}
    agent_data = AgentCreate(
        name="Test Agent",
        description="Test agent description",
        model_id=uuid4(),
        is_active=True,
        settings={"key": "value"}
    )
    
    acquire_mock = MagicMock()
    agent_service = AgentService(acquire_mock)
    
    # Act
    result = await agent_service.create(
        org_id=org_id,
        data=agent_data,
        session=db_session,
        user=user_mock
    )
    
    # Assert
    assert result.name == "Test Agent"
    assert result.organization_id == org_id

Integration Tests

Integration tests verify that different components work together correctly.

Testing Service Integration

# tests/integration/services/test_agent_kb_integration.py
import pytest
from uuid import uuid4

from src.services.agents.service import AgentService
from src.services.kb.service import KBService
from src.services.agents.schema import AgentCreate
from src.services.kb.schema import KBCreate

@pytest.mark.asyncio
async def test_agent_with_kb_integration(db_session):
    """Test integration between Agent and Knowledge Base services."""
    # Arrange
    org_id = uuid4()
    user_mock = {"id": uuid4(), "org_id": org_id}
    
    # Create a knowledge base
    kb_service = KBService(None)
    kb_data = KBCreate(
        name="Test KB",
        description="Test KB description",
        embedding_model="text-embedding-ada-002"
    )
    kb = await kb_service.create(
        org_id=org_id,
        data=kb_data,
        session=db_session,
        user=user_mock
    )
    
    # Create an agent that uses the knowledge base
    agent_service = AgentService(None)
    agent_data = AgentCreate(
        name="Test Agent with KB",
        description="Agent using KB",
        model_id=uuid4(),
        settings={"knowledge_base_id": str(kb.id)}
    )
    
    # Act
    agent = await agent_service.create(
        org_id=org_id,
        data=agent_data,
        session=db_session,
        user=user_mock
    )
    
    # Assert
    assert agent.settings.get("knowledge_base_id") == str(kb.id)
    # Additional assertions to verify integration

Integration Test Service Interaction

Testing Database Operations

# tests/integration/services/test_agent_db_operations.py
import pytest
from uuid import uuid4

from src.services.agents.service import AgentService
from src.services.agents.schema import AgentCreate, AgentUpdate

@pytest.mark.asyncio
async def test_agent_crud_operations(db_session):
    """Test CRUD operations for Agents."""
    # Arrange
    org_id = uuid4()
    user_mock = {"id": uuid4(), "org_id": org_id}
    agent_service = AgentService(None)
    
    # Act - Create
    agent_data = AgentCreate(
        name="Test Agent",
        description="Initial description",
        model_id=uuid4(),
        settings={}
    )
    agent = await agent_service.create(
        org_id=org_id,
        data=agent_data,
        session=db_session,
        user=user_mock
    )
    
    # Act - Read
    retrieved_agent = await agent_service.get(
        agent_id=agent.id,
        session=db_session,
        user=user_mock
    )
    
    # Act - Update
    update_data = AgentUpdate(
        description="Updated description"
    )
    updated_agent = await agent_service.update(
        agent_id=agent.id,
        data=update_data,
        session=db_session,
        user=user_mock
    )
    
    # Act - Delete
    delete_result = await agent_service.delete(
        agent_id=agent.id,
        session=db_session,
        user=user_mock
    )
    
    # Assert
    assert retrieved_agent.id == agent.id
    assert updated_agent.description == "Updated description"
    assert delete_result["success"] is True

CRUD Operations Sequence

End-to-End (E2E) Tests

E2E tests verify that the entire system works as expected.

Testing API Endpoints

# tests/e2e/api/test_agent_api.py
import pytest
from fastapi.testclient import TestClient
from fastapi import FastAPI
import json
from uuid import uuid4

from src.app import app
from src.database import get_db
from src.dependencies.security import JWTBearer, RBAC

# Mock dependencies
@pytest.fixture
def client(override_get_db):
    """Create a test client with overridden dependencies."""
    app.dependency_overrides[get_db] = override_get_db
    
    # Mock auth dependency
    async def override_jwt():
        return {"user_id": str(uuid4()), "org_id": str(uuid4())}
    
    async def override_rbac(*args, **kwargs):
        return {"user_id": str(uuid4()), "org_id": str(uuid4())}
    
    app.dependency_overrides[JWTBearer] = override_jwt
    app.dependency_overrides[RBAC] = override_rbac
    
    with TestClient(app) as test_client:
        yield test_client
    
    # Clear overrides
    app.dependency_overrides.clear()

def test_create_agent(client):
    """Test creating an agent via API endpoint."""
    # Arrange
    org_id = str(uuid4())
    agent_data = {
        "name": "API Test Agent",
        "description": "Created via API test",
        "model_id": str(uuid4()),
        "is_active": True,
        "settings": {"test_key": "test_value"}
    }
    
    # Act
    response = client.post(
        f"/api/agents/create?org_id={org_id}",
        json=agent_data
    )
    
    # Assert
    assert response.status_code == 200
    data = response.json()
    assert data["name"] == "API Test Agent"
    assert "id" in data

E2E Test Flow

Mocking

Proper use of mocks is essential for isolation and controlled testing.

Mocking External Services

# tests/unit/services/test_llm_service.py
import pytest
from unittest.mock import patch, MagicMock

from src.services.llm.service import LLMService

@pytest.mark.asyncio
async def test_generate_text_with_mocked_openai():
    """Test LLM service with mocked OpenAI client."""
    # Arrange
    mock_response = MagicMock()
    mock_response.choices = [MagicMock(message=MagicMock(content="Mocked response"))]
    
    # Mock the OpenAI client
    with patch("openai.AsyncClient.chat.completions.create", return_value=mock_response):
        llm_service = LLMService(None)
        
        # Act
        result = await llm_service.generate_text(
            model="gpt-4o",
            prompt="Hello, world!",
            max_tokens=100
        )
        
        # Assert
        assert result == "Mocked response"

Mocking Concept Visualization

Test Fixtures

Create reusable fixtures to reduce code duplication and simplify tests.

Model Fixtures

# tests/fixtures/models.py
import pytest
from uuid import uuid4

from src.models import AgentModel, UserModel, OrganizationModel

@pytest.fixture
async def test_organization(db_session):
    """Create a test organization."""
    org = OrganizationModel(
        name="Test Organization",
        slug="test-org",
        settings={},
        is_active=True
    )
    db_session.add(org)
    await db_session.commit()
    await db_session.refresh(org)
    return org

@pytest.fixture
async def test_user(db_session, test_organization):
    """Create a test user."""
    user = UserModel(
        email="test@example.com",
        password="hashed_password",
        first_name="Test",
        last_name="User",
        is_active=True
    )
    db_session.add(user)
    await db_session.commit()
    await db_session.refresh(user)
    return user

@pytest.fixture
async def test_agent(db_session, test_organization, test_user):
    """Create a test agent."""
    agent = AgentModel(
        organization_id=test_organization.id,
        user_id=test_user.id,
        name="Test Agent",
        description="Test agent for unit tests",
        model_id=uuid4(),  # This would be a real model ID in a real test
        is_active=True,
        settings={}
    )
    db_session.add(agent)
    await db_session.commit()
    await db_session.refresh(agent)
    return agent

Test Coverage

Aim for high test coverage of the codebase.

Running Coverage Reports

# Run tests with coverage
pytest --cov=src tests/

# Generate HTML coverage report
pytest --cov=src --cov-report=html tests/

Coverage Targets

  • Unit Tests: Aim for 80%+ coverage of all functions and methods
  • Integration Tests: Cover all major service interactions
  • E2E Tests: Cover all API endpoints and primary user flows

Continuous Integration

Tests will be integrated into the CI/CD pipeline to ensure code quality.

CI/CD Workflow

GitHub Actions Configuration

Update the .github/workflows/pre-commit.yml file to include testing:

name: Tests and Linting
on:
  pull_request:
    branches: [main, dev]
  push:
    branches: [main, dev]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_USER: postgres
          POSTGRES_DB: test_zyeta
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.10'
          cache: 'pip'
      
      - name: Install Poetry
        run: |
          curl -sSL https://install.python-poetry.org | python3 -
          poetry config virtualenvs.create false
      
      - name: Install dependencies
        run: |
          poetry install --no-interaction
      
      - name: Run tests
        run: |
          pytest --cov=src tests/
          
      - name: Generate coverage report
        run: |
          pytest --cov=src --cov-report=xml tests/
      
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage.xml

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - run: pip install ruff
      - run: pip install mypy
      - run: pip install pre-commit
      - run: pre-commit run --all-files

Testing Strategies by Component

Models

  • Test model instantiation
  • Test relationships between models
  • Test constraints and defaults

Services

  • Test business logic
  • Test error handling
  • Test edge cases
  • Mock external dependencies

API Endpoints

  • Test request validation
  • Test authentication/authorization
  • Test response format
  • Test error responses

Component Testing Focus

Best Practices

General Guidelines

  1. Test pyramid: Write more unit tests than integration tests, and more integration tests than E2E tests
  2. Test isolation: Each test should be independent and not rely on the state from other tests
  3. Descriptive naming: Use clear, descriptive names for test functions
  4. Arrange-Act-Assert: Structure tests with clear sections for setup, execution, and verification
  5. Don’t test implementation details: Focus on behavior, not implementation

Test Organization Pattern

Asynchronous Testing

Since our application uses async/await, follow these practices:

  1. Use pytest.mark.asyncio decorator for async test functions
  2. Use async fixtures where needed
  3. Handle event loops properly
@pytest.mark.asyncio
async def test_async_function():
    # Test async code here
    result = await some_async_function()
    assert result == expected_value

Testing Database Operations

  1. Use transactions to isolate tests
  2. Roll back after each test
  3. Use a separate test database

Common Pitfalls

  1. Slow tests: Keep tests fast, especially unit tests
  2. Flaky tests: Avoid tests that sometimes pass and sometimes fail
  3. Over-mocking: Don’t mock everything; test real interactions where practical
  4. Hardcoded test data: Use fixtures and factories for test data
  5. Missing edge cases: Test error paths, not just happy paths

Testing Pitfalls and Solutions

Conclusion

A comprehensive testing strategy is essential for maintaining a healthy codebase. By following this guide, developers can write effective tests that ensure the reliability and quality of the Zyeta backend.

Remember that tests are an investment in the future of the codebase, making it safer to refactor and extend functionality while maintaining confidence in the system’s behavior.

Additional Resources