fastapi-testing

FastAPI Testing Setup

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "fastapi-testing" with this command: npx skills add agusmdev/burntop/agusmdev-burntop-fastapi-testing

FastAPI Testing Setup

Overview

This skill covers setting up pytest-asyncio for integration testing FastAPI routers with a test database.

Test Configuration in pyproject.toml

Ensure pyproject.toml has:

[tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] pythonpath = ["src"]

Add Test Database URL to Config

Update src/app/config.py :

from pydantic import PostgresDsn from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False, )

# ... existing settings ...

# Test database (optional, falls back to modifying database_url)
test_database_url: PostgresDsn | None = None

settings = Settings()

Update .env.example :

Test database

TEST_DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/dbname_test

Create tests/conftest.py

Create tests/conftest.py :

import asyncio from collections.abc import AsyncGenerator, Generator

import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, create_async_engine, )

from app.config import settings from app.core.models import Base from app.dependencies import get_db from app.main import app

Get test database URL

def get_test_database_url() -> str: """Get the test database URL.""" if settings.test_database_url: return str(settings.test_database_url) # Fallback: modify main database URL to use test database main_url = str(settings.database_url) return main_url.replace("/dbname", "/dbname_test")

Create test engine

test_engine = create_async_engine( get_test_database_url(), echo=False, pool_pre_ping=True, )

Create test session factory

test_async_session_factory = async_sessionmaker( bind=test_engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False, )

@pytest.fixture(scope="session") def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]: """ Create an event loop for the test session.

This fixture is required for session-scoped async fixtures.
"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()

@pytest.fixture(scope="session", autouse=True) async def setup_database() -> AsyncGenerator[None, None]: """ Set up the test database schema.

Creates all tables before tests run and drops them after.
Runs once per test session.
"""
# Import all models to ensure they're registered
from app.items.models import Item  # noqa: F401
# Add other model imports here

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

yield

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

await test_engine.dispose()

@pytest.fixture async def db_session() -> AsyncGenerator[AsyncSession, None]: """ Provide a database session for a single test.

Each test gets its own session with automatic rollback.
This ensures test isolation.
"""
async with test_async_session_factory() as session:
    yield session
    # Rollback any uncommitted changes
    await session.rollback()

@pytest.fixture async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: """ Provide an async HTTP client for testing API endpoints.

Overrides the database dependency to use the test session.
"""
# Override the database dependency
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
    yield db_session

app.dependency_overrides[get_db] = override_get_db

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

# Clear overrides after test
app.dependency_overrides.clear()

@pytest.fixture def sample_item_data() -> dict: """Provide sample data for creating an item.""" return { "name": "Test Item", "description": "A test item description", }

@pytest.fixture async def created_item( client: AsyncClient, sample_item_data: dict, ) -> dict: """Create an item and return the response data.""" response = await client.post("/api/v1/items", json=sample_item_data) assert response.status_code == 201 return response.json()

Create Test Files

tests/api/init.py

Empty file

tests/api/v1/init.py

Empty file

tests/api/v1/test_items.py

Create tests/api/v1/test_items.py :

import pytest from httpx import AsyncClient

class TestCreateItem: """Tests for POST /api/v1/items"""

async def test_create_item_success(
    self,
    client: AsyncClient,
    sample_item_data: dict,
) -> None:
    """Test successful item creation."""
    response = await client.post("/api/v1/items", json=sample_item_data)

    assert response.status_code == 201
    data = response.json()
    assert data["name"] == sample_item_data["name"]
    assert data["description"] == sample_item_data["description"]
    assert "id" in data
    assert "created_at" in data
    assert "updated_at" in data

async def test_create_item_duplicate_name(
    self,
    client: AsyncClient,
    created_item: dict,
    sample_item_data: dict,
) -> None:
    """Test that creating item with duplicate name fails."""
    response = await client.post("/api/v1/items", json=sample_item_data)

    assert response.status_code == 409
    data = response.json()
    assert data["error_code"] == "CONFLICT"

async def test_create_item_missing_name(
    self,
    client: AsyncClient,
) -> None:
    """Test that name is required."""
    response = await client.post("/api/v1/items", json={"description": "test"})

    assert response.status_code == 422
    data = response.json()
    assert data["error_code"] == "VALIDATION_ERROR"

async def test_create_item_empty_name(
    self,
    client: AsyncClient,
) -> None:
    """Test that empty name is rejected."""
    response = await client.post("/api/v1/items", json={"name": ""})

    assert response.status_code == 422

class TestGetItem: """Tests for GET /api/v1/items/{id}"""

async def test_get_item_success(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test successful item retrieval."""
    item_id = created_item["id"]
    response = await client.get(f"/api/v1/items/{item_id}")

    assert response.status_code == 200
    data = response.json()
    assert data["id"] == item_id
    assert data["name"] == created_item["name"]

async def test_get_item_not_found(
    self,
    client: AsyncClient,
) -> None:
    """Test 404 for non-existent item."""
    fake_id = "00000000-0000-0000-0000-000000000000"
    response = await client.get(f"/api/v1/items/{fake_id}")

    assert response.status_code == 404
    data = response.json()
    assert data["error_code"] == "NOT_FOUND"

async def test_get_item_invalid_uuid(
    self,
    client: AsyncClient,
) -> None:
    """Test 422 for invalid UUID format."""
    response = await client.get("/api/v1/items/not-a-uuid")

    assert response.status_code == 422

class TestListItems: """Tests for GET /api/v1/items"""

async def test_list_items_empty(
    self,
    client: AsyncClient,
) -> None:
    """Test listing items when empty."""
    response = await client.get("/api/v1/items")

    assert response.status_code == 200
    data = response.json()
    assert data["items"] == []
    assert data["total"] == 0

async def test_list_items_with_data(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test listing items with data."""
    response = await client.get("/api/v1/items")

    assert response.status_code == 200
    data = response.json()
    assert len(data["items"]) >= 1
    assert data["total"] >= 1

async def test_list_items_pagination(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test pagination parameters."""
    response = await client.get("/api/v1/items?page=1&size=10")

    assert response.status_code == 200
    data = response.json()
    assert "page" in data
    assert "size" in data
    assert data["page"] == 1
    assert data["size"] == 10

async def test_list_items_filter_by_name(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test filtering by name."""
    name = created_item["name"]
    response = await client.get(f"/api/v1/items?name={name}")

    assert response.status_code == 200
    data = response.json()
    assert all(item["name"] == name for item in data["items"])

async def test_list_items_filter_ilike(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test case-insensitive filtering."""
    response = await client.get("/api/v1/items?name__ilike=test")

    assert response.status_code == 200
    data = response.json()
    assert all("test" in item["name"].lower() for item in data["items"])

class TestUpdateItem: """Tests for PATCH /api/v1/items/{id}"""

async def test_update_item_success(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test successful item update."""
    item_id = created_item["id"]
    update_data = {"name": "Updated Name"}
    response = await client.patch(f"/api/v1/items/{item_id}", json=update_data)

    assert response.status_code == 200
    data = response.json()
    assert data["name"] == "Updated Name"
    assert data["description"] == created_item["description"]  # Unchanged

async def test_update_item_partial(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test partial update (only description)."""
    item_id = created_item["id"]
    update_data = {"description": "New description"}
    response = await client.patch(f"/api/v1/items/{item_id}", json=update_data)

    assert response.status_code == 200
    data = response.json()
    assert data["name"] == created_item["name"]  # Unchanged
    assert data["description"] == "New description"

async def test_update_item_not_found(
    self,
    client: AsyncClient,
) -> None:
    """Test update non-existent item."""
    fake_id = "00000000-0000-0000-0000-000000000000"
    response = await client.patch(f"/api/v1/items/{fake_id}", json={"name": "test"})

    assert response.status_code == 404

class TestDeleteItem: """Tests for DELETE /api/v1/items/{id}"""

async def test_delete_item_success(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test successful item deletion (soft delete)."""
    item_id = created_item["id"]
    response = await client.delete(f"/api/v1/items/{item_id}")

    assert response.status_code == 204

    # Verify item is no longer accessible
    get_response = await client.get(f"/api/v1/items/{item_id}")
    assert get_response.status_code == 404

async def test_delete_item_not_found(
    self,
    client: AsyncClient,
) -> None:
    """Test delete non-existent item."""
    fake_id = "00000000-0000-0000-0000-000000000000"
    response = await client.delete(f"/api/v1/items/{fake_id}")

    assert response.status_code == 404

class TestRestoreItem: """Tests for POST /api/v1/items/{id}/restore"""

async def test_restore_item_success(
    self,
    client: AsyncClient,
    created_item: dict,
) -> None:
    """Test restoring a soft-deleted item."""
    item_id = created_item["id"]

    # Delete the item
    await client.delete(f"/api/v1/items/{item_id}")

    # Restore the item
    response = await client.post(f"/api/v1/items/{item_id}/restore")

    assert response.status_code == 200
    data = response.json()
    assert data["id"] == item_id

    # Verify item is accessible again
    get_response = await client.get(f"/api/v1/items/{item_id}")
    assert get_response.status_code == 200

Running Tests

Run all tests

uv run pytest

Run with verbose output

uv run pytest -v

Run specific test file

uv run pytest tests/api/v1/test_items.py

Run specific test class

uv run pytest tests/api/v1/test_items.py::TestCreateItem

Run specific test

uv run pytest tests/api/v1/test_items.py::TestCreateItem::test_create_item_success

Run with coverage

uv run pytest --cov=app --cov-report=html

Run tests matching a pattern

uv run pytest -k "create"

Additional Test Fixtures

Factory Fixtures

@pytest.fixture def item_factory(): """Factory for creating item data with unique names.""" counter = 0

def _factory(**overrides):
    nonlocal counter
    counter += 1
    defaults = {
        "name": f"Item {counter}",
        "description": f"Description {counter}",
    }
    defaults.update(overrides)
    return defaults

return _factory

async def test_with_factory(client: AsyncClient, item_factory): """Test using factory fixture.""" item1 = await client.post("/api/v1/items", json=item_factory()) item2 = await client.post("/api/v1/items", json=item_factory())

assert item1.json()["name"] != item2.json()["name"]

Multiple Items Fixture

@pytest.fixture async def multiple_items( client: AsyncClient, item_factory, ) -> list[dict]: """Create multiple items for testing.""" items = [] for i in range(5): response = await client.post("/api/v1/items", json=item_factory()) items.append(response.json()) return items

Test Database Setup

Before running tests, create the test database:

PostgreSQL

createdb dbname_test

Or via psql

psql -c "CREATE DATABASE dbname_test;"

Testing Best Practices

  • Test isolation: Each test should be independent

  • Use fixtures: Share setup code via fixtures

  • Test edge cases: Empty data, invalid input, not found

  • Test error responses: Verify error codes and messages

  • Descriptive names: Test names should describe the scenario

  • One assertion focus: Each test should focus on one behavior

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

fastapi-exceptions

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

fastapi-logging

No summary provided by upstream source.

Repository SourceNeeds Review
Coding

fastapi-app-factory

No summary provided by upstream source.

Repository SourceNeeds Review