{"slug": "testing-fastapi-endpoints-from-unit-tests-to-integration-tests-with-postgresql", "title": "Testing FastAPI Endpoints: From Unit Tests to Integration Tests with PostgreSQL", "summary": "A developer building CitizenApp with nine AI features advocates for integration tests with real PostgreSQL databases over mocked unit tests in FastAPI, arguing that mocking tests assumptions rather than code and misses critical bugs like transaction isolation issues. The developer shares a test fixture setup using transaction rollbacks and a multi-tenant workspace endpoint example to demonstrate production-grade testing practices.", "body_md": "I've shipped CitizenApp with nine AI features in production, and I can tell you with absolute certainty: bad tests in FastAPI will destroy your confidence faster than any outage. I'm not talking about unit tests that mock everything into oblivion—those are security theater. I'm talking about *real* tests that hit PostgreSQL, verify transactional behavior, and catch the subtle data corruption bugs that only appear under load.\n\nHere's what actually works, and why most FastAPI test guides miss the point entirely.\n\nMost tutorials show you how to mock the database. That's fine for toy projects. In production, I've watched carefully mocked unit tests pass while the actual endpoint corrupted customer data because of transaction isolation issues, cascading deletes, or constraint violations that only emerge with real schema interactions.\n\nThe problem: when you mock everything, you're testing your assumptions, not your code. Your assumptions are usually wrong.\n\nI prefer integration tests that use a real PostgreSQL database (or a container) because they catch the bugs that matter. Yes, they're slower. No, you don't run them on every keystroke. But they're the difference between shipping with confidence and shipping with prayers.\n\nStart with the right fixtures. Here's my baseline setup:\n\n``` python\n# tests/conftest.py\nimport os\nimport pytest\nfrom sqlalchemy import create_engine, text\nfrom sqlalchemy.orm import sessionmaker, Session\nfrom sqlalchemy.pool import StaticPool\n\nfrom app.db import Base\nfrom app.main import app\nfrom fastapi.testclient import TestClient\n\n# Use an in-memory SQLite for speed, OR a real Postgres test database\n# I prefer Postgres because SQLite doesn't catch all constraint violations\nDATABASE_URL = os.getenv(\n    \"TEST_DATABASE_URL\",\n    \"postgresql://postgres:password@localhost:5432/test_db\"\n)\n\nengine = create_engine(\n    DATABASE_URL,\n    echo=False,  # Set to True to see SQL statements\n)\n\nTestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\n\n@pytest.fixture(scope=\"session\")\ndef db_engine():\n    \"\"\"Create tables once per test session.\"\"\"\n    Base.metadata.create_all(bind=engine)\n    yield engine\n    Base.metadata.drop_all(bind=engine)\n\n@pytest.fixture\ndef db_session(db_engine):\n    \"\"\"\n    Create a new database session for each test.\n    Rollback after the test to keep the database clean.\n    \"\"\"\n    connection = db_engine.connect()\n    transaction = connection.begin()\n    session = TestingSessionLocal(bind=connection)\n\n    yield session\n\n    session.close()\n    transaction.rollback()\n    connection.close()\n\n@pytest.fixture\ndef client(db_session: Session):\n    \"\"\"Override the dependency injection to use our test session.\"\"\"\n    def override_get_db():\n        yield db_session\n\n    from app.main import get_db\n    app.dependency_overrides[get_db] = override_get_db\n\n    yield TestClient(app)\n\n    app.dependency_overrides.clear()\n```\n\nWhy this structure? The transaction rollback is critical. Each test runs inside a transaction that gets rolled back after the test completes. This keeps your test database clean without dropping tables between tests, and it's *fast*.\n\nHere's a real endpoint from CitizenApp—a multi-tenant API that creates workspaces:\n\n``` python\n# app/api/workspaces.py\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom sqlalchemy.orm import Session\nfrom app.db import get_db\nfrom app.models import Workspace, User\nfrom app.schemas import WorkspaceCreate, WorkspaceResponse\n\nrouter = APIRouter()\n\n@router.post(\"/workspaces\", response_model=WorkspaceResponse)\nasync def create_workspace(\n    workspace: WorkspaceCreate,\n    user: User = Depends(get_current_user),\n    db: Session = Depends(get_db),\n):\n    \"\"\"Create a workspace and assign the user as owner.\"\"\"\n\n    # Check for duplicate names within user's workspaces\n    existing = db.query(Workspace).filter(\n        Workspace.owner_id == user.id,\n        Workspace.name == workspace.name,\n    ).first()\n\n    if existing:\n        raise HTTPException(\n            status_code=status.HTTP_409_CONFLICT,\n            detail=\"Workspace name already exists\",\n        )\n\n    new_workspace = Workspace(\n        name=workspace.name,\n        owner_id=user.id,\n    )\n    db.add(new_workspace)\n    db.commit()\n    db.refresh(new_workspace)\n    return new_workspace\n```\n\nNow, here's the test that catches real bugs:\n\n``` python\n# tests/test_workspaces.py\nimport pytest\nfrom fastapi import status\nfrom app.models import User, Workspace\nfrom app.schemas import WorkspaceCreate\n\n@pytest.fixture\ndef test_user(db_session):\n    \"\"\"Create a test user.\"\"\"\n    user = User(email=\"test@example.com\", hashed_password=\"fake_hash\")\n    db_session.add(user)\n    db_session.commit()\n    db_session.refresh(user)\n    return user\n\n@pytest.fixture\ndef auth_header(test_user):\n    \"\"\"Return auth header for test user.\"\"\"\n    # In reality, you'd generate a valid JWT\n    return {\"Authorization\": f\"Bearer {test_user.id}\"}\n\ndef test_create_workspace_success(client, test_user, auth_header):\n    \"\"\"Happy path: create a workspace.\"\"\"\n    response = client.post(\n        \"/workspaces\",\n        json={\"name\": \"My First Workspace\"},\n        headers=auth_header,\n    )\n    assert response.status_code == status.HTTP_200_OK\n    data = response.json()\n    assert data[\"name\"] == \"My First Workspace\"\n    assert data[\"owner_id\"] == test_user.id\n\ndef test_create_duplicate_workspace_name(client, test_user, auth_header, db_session):\n    \"\"\"This test catches a real bug: duplicate name in same user's workspace.\"\"\"\n    # Create first workspace\n    workspace1 = Workspace(name=\"Duplicate\", owner_id=test_user.id)\n    db_session.add(workspace1)\n    db_session.commit()\n\n    # Try to create duplicate\n    response = client.post(\n        \"/workspaces\",\n        json={\"name\": \"Duplicate\"},\n        headers=auth_header,\n    )\n    assert response.status_code == status.HTTP_409_CONFLICT\n\ndef test_different_users_can_have_same_workspace_name(\n    client, db_session, auth_header\n):\n    \"\"\"Multi-tenant isolation: user A and user B can both have 'Workspace'.\"\"\"\n    user_b = User(email=\"user_b@example.com\", hashed_password=\"hash\")\n    db_session.add(user_b)\n    db_session.commit()\n\n    # User A creates \"My Workspace\"\n    response_a = client.post(\n        \"/workspaces\",\n        json={\"name\": \"My Workspace\"},\n        headers=auth_header,\n    )\n    assert response_a.status_code == status.HTTP_200_OK\n\n    # User B also creates \"My Workspace\" (different owner_id)\n    auth_header_b = {\"Authorization\": f\"Bearer {user_b.id}\"}\n    response_b = client.post(\n        \"/workspaces\",\n        json={\"name\": \"My Workspace\"},\n        headers=auth_header_b,\n    )\n    assert response_b.status_code == status.HTTP_200_OK\n    assert response_a.json()[\"id\"] != response_b.json()[\"id\"]\n```\n\nWhen you have 9 AI features, you need to test variations efficiently:\n\n```\n@pytest.mark.parametrize(\n    \"workspace_name,expected_status\",\n    [\n        (\"Valid Workspace\", 200),\n        (\"\", 422),  # Empty name\n        (\"x\" * 256, 200),  # Very long name (assuming your schema allows it)\n        (None, 422),  # Missing field\n    ],\n)\ndef test_workspace_name_validation(\n    client, test_user, auth_header, workspace_name, expected_status\n):\n    \"\"\"Test name validation with multiple inputs.\"\"\"\n    response = client.post(\n        \"/workspaces\",\n        json={\"name\": workspace_name} if workspace_name is not None else {},\n        headers=auth_header,\n    )\n    assert response.status_code == expected_status\n```\n\nHere's where I burned an hour: if you're using async endpoints with `asyncio`\n\nin tests, the transaction rollback might not work as expected. FastAPI's TestClient runs async in a special way.\n\nMy workaround:\n\n``` python\n@pytest.fixture\ndef client(db_session: Session):\n    \"\"\"For async endpoints, ensure the session is thread-safe.\"\"\"\n    import asyncio\n    from sqlalchemy.orm import scoped_session\n\n    scoped = scoped_session(TestingSessionLocal)\n\n    def override_get_db():\n        try:\n            yield scoped()\n        finally:\n            scoped.remove()\n\n    from app.main import get_db\n    app.dependency_overrides[get_db] = override_get_db\n\n    yield TestClient(app)\n    app.dependency_overrides.clear()\n```\n\nUse GitHub Actions with a Postgres container:\n\n```\nyaml\n# .github/workflows/test.yml\nname: Tests\n\non: [push, pull_request]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    services:\n      postgres:\n        image: postgres:15\n        env:\n          POSTGRES_PASSWORD: postgres\n          POSTGRES_DB: test_db\n        options: >-\n          --health-cmd pg_isready\n          --health-interval 10s\n          --health-timeout 5s\n          --health-retries 5\n        ports:\n          - 5432:5432\n\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-python@v4\n        with:\n          python\n```\n\n", "url": "https://wpnews.pro/news/testing-fastapi-endpoints-from-unit-tests-to-integration-tests-with-postgresql", "canonical_source": "https://dev.to/uaslimcreate/testing-fastapi-endpoints-from-unit-tests-to-integration-tests-with-postgresql-36dj", "published_at": "2026-07-20 14:37:51+00:00", "updated_at": "2026-07-20 14:50:21.805993+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["CitizenApp", "FastAPI", "PostgreSQL", "SQLAlchemy"], "alternates": {"html": "https://wpnews.pro/news/testing-fastapi-endpoints-from-unit-tests-to-integration-tests-with-postgresql", "markdown": "https://wpnews.pro/news/testing-fastapi-endpoints-from-unit-tests-to-integration-tests-with-postgresql.md", "text": "https://wpnews.pro/news/testing-fastapi-endpoints-from-unit-tests-to-integration-tests-with-postgresql.txt", "jsonld": "https://wpnews.pro/news/testing-fastapi-endpoints-from-unit-tests-to-integration-tests-with-postgresql.jsonld"}}