Skip to content

Backend Testing (Python/FastAPI)

Stack Overview

Tool Purpose
pytest Test runner and framework
pytest-asyncio Async test support
pytest-cov Coverage reporting
pytest-xdist Parallel test execution
httpx Async HTTP client for API tests
Testcontainers Real PostgreSQL for E2E tests
freezegun Time manipulation for tests

Test Organization

tests/
├── conftest.py              # Shared fixtures (db, client, auth)
├── api/
│   ├── conftest.py          # API-specific fixtures
│   ├── test_auth.py         # Authentication endpoints
│   ├── test_users.py        # User endpoints
│   └── dependencies/
│       └── test_auth_dependencies.py
├── services/
│   ├── test_auth_service.py
│   └── test_email_service.py
├── crud/
│   ├── test_base.py         # Generic CRUD operations
│   └── test_user.py
├── models/
│   └── test_user.py
└── e2e/
    ├── conftest.py          # E2E fixtures (Testcontainers)
    └── test_user_workflows.py

Fixtures & Factories

Database Fixtures

Use function scope for test isolation — each test gets a fresh database:

# tests/conftest.py
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.models import Base


@pytest_asyncio.fixture(scope="function")
async def async_test_db():
    """Create a fresh in-memory database for each test."""
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    AsyncTestingSessionLocal = sessionmaker(
        engine, class_=AsyncSession, expire_on_commit=False
    )

    yield engine, AsyncTestingSessionLocal

    await engine.dispose()

User Fixtures

@pytest_asyncio.fixture
async def test_user(async_test_db):
    """Create a standard test user."""
    _, AsyncTestingSessionLocal = async_test_db

    async with AsyncTestingSessionLocal() as session:
        user = User(
            email="testuser@example.com",
            password_hash=get_password_hash("TestPassword123!"),
            is_active=True,
        )
        session.add(user)
        await session.commit()
        await session.refresh(user)
        return user


@pytest_asyncio.fixture
async def test_superuser(async_test_db):
    """Create an admin test user."""
    _, AsyncTestingSessionLocal = async_test_db

    async with AsyncTestingSessionLocal() as session:
        user = User(
            email="superuser@example.com",
            password_hash=get_password_hash("AdminPassword123!"),
            is_active=True,
            is_superuser=True,
        )
        session.add(user)
        await session.commit()
        await session.refresh(user)
        return user

Client Fixtures

from httpx import AsyncClient, ASGITransport
from app.main import app
from app.api.dependencies import get_db


@pytest_asyncio.fixture
async def client(async_test_db):
    """Async test client with database override."""
    _, AsyncTestingSessionLocal = async_test_db

    async def override_get_db():
        async with AsyncTestingSessionLocal() as session:
            yield session

    app.dependency_overrides[get_db] = override_get_db

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test"
    ) as client:
        yield client

    app.dependency_overrides.clear()

Token Fixtures

@pytest_asyncio.fixture
async def user_token(client, test_user):
    """Get an access token for the test user."""
    response = await client.post(
        "/api/v1/auth/login",
        json={"email": "testuser@example.com", "password": "TestPassword123!"}
    )
    return response.json()["access_token"]


@pytest_asyncio.fixture
async def superuser_token(client, test_superuser):
    """Get an access token for the superuser."""
    response = await client.post(
        "/api/v1/auth/login",
        json={"email": "superuser@example.com", "password": "AdminPassword123!"}
    )
    return response.json()["access_token"]

API Testing Patterns

Basic Endpoint Test

import pytest


class TestUserEndpoints:

    @pytest.mark.asyncio
    async def test_get_current_user_returns_user_data(self, client, user_token):
        response = await client.get(
            "/api/v1/users/me",
            headers={"Authorization": f"Bearer {user_token}"}
        )

        assert response.status_code == 200
        data = response.json()
        assert data["email"] == "testuser@example.com"
        assert "password" not in data  # Sensitive data excluded

    @pytest.mark.asyncio
    async def test_get_current_user_without_token_returns_401(self, client):
        response = await client.get("/api/v1/users/me")

        assert response.status_code == 401

Testing All Response Codes

class TestLoginEndpoint:

    @pytest.mark.asyncio
    async def test_login_with_valid_credentials_returns_tokens(self, client, test_user):
        response = await client.post(
            "/api/v1/auth/login",
            json={"email": "testuser@example.com", "password": "TestPassword123!"}
        )

        assert response.status_code == 200
        data = response.json()
        assert "access_token" in data
        assert "refresh_token" in data

    @pytest.mark.asyncio
    async def test_login_with_wrong_password_returns_401(self, client, test_user):
        response = await client.post(
            "/api/v1/auth/login",
            json={"email": "testuser@example.com", "password": "WrongPassword"}
        )

        assert response.status_code == 401
        assert response.json()["detail"] == "Invalid credentials"

    @pytest.mark.asyncio
    async def test_login_with_nonexistent_user_returns_401(self, client):
        response = await client.post(
            "/api/v1/auth/login",
            json={"email": "nobody@example.com", "password": "Password123!"}
        )

        assert response.status_code == 401
        # Same message as wrong password (prevent user enumeration)
        assert response.json()["detail"] == "Invalid credentials"

    @pytest.mark.asyncio
    async def test_login_with_invalid_email_returns_422(self, client):
        response = await client.post(
            "/api/v1/auth/login",
            json={"email": "not-an-email", "password": "Password123!"}
        )

        assert response.status_code == 422  # Pydantic validation error

Class-Based Organization

Group related tests in classes:

class TestListUsers:
    """GET /api/v1/users (admin only)"""

    @pytest.mark.asyncio
    async def test_list_users_as_admin_returns_users(self, client, superuser_token):
        response = await client.get(
            "/api/v1/users",
            headers={"Authorization": f"Bearer {superuser_token}"}
        )
        assert response.status_code == 200
        assert isinstance(response.json(), list)

    @pytest.mark.asyncio
    async def test_list_users_as_regular_user_returns_403(self, client, user_token):
        response = await client.get(
            "/api/v1/users",
            headers={"Authorization": f"Bearer {user_token}"}
        )
        assert response.status_code == 403

    @pytest.mark.asyncio
    async def test_list_users_without_auth_returns_401(self, client):
        response = await client.get("/api/v1/users")
        assert response.status_code == 401

Service Layer Testing

Test business logic directly, mocking only external dependencies:

from unittest.mock import AsyncMock, patch
import pytest

from app.services.email import EmailService


class TestEmailService:

    @pytest.mark.asyncio
    async def test_send_password_reset_sends_email_with_token(self):
        # Arrange
        backend_mock = AsyncMock()
        backend_mock.send_email = AsyncMock(return_value=True)
        service = EmailService(backend=backend_mock)

        # Act
        await service.send_password_reset(
            email="user@example.com",
            token="abc123",
            reset_url="https://app.sartiq.com/reset"
        )

        # Assert
        backend_mock.send_email.assert_called_once()
        call_kwargs = backend_mock.send_email.call_args.kwargs
        assert call_kwargs["to"] == "user@example.com"
        assert "abc123" in call_kwargs["html_content"]

    @pytest.mark.asyncio
    async def test_send_password_reset_handles_backend_failure(self):
        backend_mock = AsyncMock()
        backend_mock.send_email = AsyncMock(return_value=False)
        service = EmailService(backend=backend_mock)

        with pytest.raises(EmailDeliveryError):
            await service.send_password_reset(
                email="user@example.com",
                token="abc123",
                reset_url="https://app.sartiq.com/reset"
            )

Database Testing

Using SQLite for Speed

For most tests, SQLite in-memory is fast and sufficient:

@pytest.mark.asyncio
async def test_create_user_persists_to_database(async_test_db):
    _, AsyncTestingSessionLocal = async_test_db

    async with AsyncTestingSessionLocal() as session:
        user = User(email="new@example.com", password_hash="hashed")
        session.add(user)
        await session.commit()

        # Verify persistence
        result = await session.execute(
            select(User).where(User.email == "new@example.com")
        )
        saved_user = result.scalar_one()
        assert saved_user.id is not None

Using PostgreSQL for E2E

For tests that need PostgreSQL-specific features or full fidelity:

# tests/e2e/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer


@pytest.fixture(scope="session")
def postgres_container():
    """Start PostgreSQL container once per test session."""
    with PostgresContainer("postgres:17-alpine") as postgres:
        yield postgres


@pytest_asyncio.fixture(scope="function")
async def e2e_db_session(postgres_container):
    """Fresh schema per test using real PostgreSQL."""
    url = postgres_container.get_connection_url()
    async_url = url.replace("postgresql://", "postgresql+asyncpg://")

    engine = create_async_engine(async_url)

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
        await conn.run_sync(Base.metadata.create_all)

    AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession)

    async with AsyncSessionLocal() as session:
        yield session

    await engine.dispose()

Dependency Override Pattern

Override FastAPI dependencies to inject test databases:

from app.api.dependencies import get_db


async def override_get_db():
    async with AsyncTestingSessionLocal() as session:
        yield session


app.dependency_overrides[get_db] = override_get_db
# ... run tests ...
app.dependency_overrides.clear()  # Clean up after

Mocking Strategy

Mock at Boundaries

Mock external services, not internal logic:

# Good: Mock the email backend
with patch("app.services.email.SMTPBackend.send") as mock_send:
    mock_send.return_value = True
    await email_service.send_welcome_email(user)

# Bad: Mock internal method
with patch.object(email_service, "_format_template"):  # Don't do this
    ...

AsyncMock for Async Code

from unittest.mock import AsyncMock, patch


@pytest.mark.asyncio
async def test_payment_service_charges_card():
    with patch("app.services.payment.stripe_client") as mock_stripe:
        mock_stripe.charges.create = AsyncMock(return_value={"id": "ch_123"})

        result = await payment_service.charge(amount=1000, customer_id="cus_456")

        assert result.charge_id == "ch_123"
        mock_stripe.charges.create.assert_called_once_with(
            amount=1000,
            currency="usd",
            customer="cus_456"
        )

Simulating Database Errors

from sqlalchemy.exc import IntegrityError


@pytest.mark.asyncio
async def test_create_user_handles_duplicate_email(async_test_db):
    _, AsyncTestingSessionLocal = async_test_db

    async with AsyncTestingSessionLocal() as session:
        # Create first user
        user1 = User(email="dupe@example.com", password_hash="hash1")
        session.add(user1)
        await session.commit()

        # Attempt duplicate
        user2 = User(email="dupe@example.com", password_hash="hash2")
        session.add(user2)

        with pytest.raises(IntegrityError):
            await session.commit()

Error & Edge Case Testing

Testing Exceptions

import pytest
from app.exceptions import NotFoundError


@pytest.mark.asyncio
async def test_get_user_raises_not_found_for_missing_user(user_service):
    with pytest.raises(NotFoundError) as exc_info:
        await user_service.get_user(id="nonexistent-uuid")

    assert "User not found" in str(exc_info.value)

Testing Validation Errors

@pytest.mark.asyncio
async def test_create_user_rejects_weak_password(client):
    response = await client.post(
        "/api/v1/auth/register",
        json={"email": "user@example.com", "password": "123"}
    )

    assert response.status_code == 422
    errors = response.json()["detail"]
    assert any("password" in e["loc"] for e in errors)

Parameterized Tests

Test multiple scenarios efficiently:

import pytest


@pytest.mark.parametrize("email,expected_valid", [
    ("user@example.com", True),
    ("user@sartiq.com", True),
    ("user@subdomain.example.com", True),
    ("invalid", False),
    ("@example.com", False),
    ("user@", False),
    ("", False),
])
def test_email_validation(email, expected_valid):
    result = validate_email(email)
    assert result == expected_valid

Running Tests

# Run all tests
pytest

# Run with verbose output
pytest -v

# Stop on first failure
pytest -x

# Run specific file
pytest tests/api/test_auth.py

# Run specific test
pytest tests/api/test_auth.py::TestLogin::test_login_success -v

# Run by marker
pytest -m "not e2e"              # Skip E2E tests
pytest -m e2e                    # Only E2E tests

# Run with coverage
pytest --cov=app --cov-report=term-missing

# Run in parallel (faster)
pytest -n auto

Coverage Configuration

In pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = "-v --tb=short --strict-markers"
markers = [
    "e2e: End-to-end tests with real database",
    "slow: Tests that take a long time",
]

[tool.coverage.run]
source = ["app"]
branch = true
omit = [
    "app/alembic/*",
    "app/scripts/*",
    "tests/*",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
]
fail_under = 80

Checklist

Before merging:

  • All tests pass locally (pytest)
  • New code has test coverage
  • Tests are independent (can run in any order)
  • No flaky tests introduced
  • Edge cases covered (empty, null, invalid, concurrent)
  • Error paths tested (401, 403, 404, 422, 500)
  • Async code tested with @pytest.mark.asyncio