Skip to content

Latest commit

 

History

History
97 lines (69 loc) · 3.61 KB

testing.md

File metadata and controls

97 lines (69 loc) · 3.61 KB

Testing

pytest with fixture-based dependency injection and async support.

Configuration (pyproject.toml)

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
asyncio_mode = "strict"
addopts = "--cov=src --cov-report=term-missing --no-header -q"
  • asyncio_mode = "strict" — requires explicit @pytest.mark.asyncio on async tests. Prevents accidental async tests (forgetting to await) from silently passing.
  • --cov=src --cov-report=term-missing — show coverage with uncovered line numbers.

Key Plugins

Plugin Purpose
pytest-asyncio Run async test functions and fixtures
pytest-cov Measure code coverage
httpx Async HTTP client used as FastAPI test client
asyncpg Async Postgres driver (same as production)

Alternatives to pytest: unittest (stdlib, class-based, more verbose), nose2 (legacy).

Test Fixtures (conftest.py)

Test Database

TEST_DATABASE_URL = "postgresql+asyncpg://acme@localhost:5432/acme_test"
engine = create_async_engine(TEST_DATABASE_URL)

Uses a separate Postgres database (acme_test), created by docker/init-test-db.sql on first container startup:

  • Same engine as dev and production — no dialect mismatches
  • Each test gets a fresh, isolated schema via create_all / drop_all

db Fixture

@pytest_asyncio.fixture
async def db() -> AsyncIterator[AsyncSession]:
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)   # Create tables
    async with async_session() as session:
        yield session                                     # Test runs here
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)      # Drop tables

Each test gets a clean database: tables created before, dropped after. engine.begin() provides a transaction for DDL operations (CREATE TABLE, DROP TABLE).

client Fixture

@pytest_asyncio.fixture
async def client(db: AsyncSession) -> AsyncIterator[AsyncClient]:
    async def override_get_db() -> AsyncIterator[AsyncSession]:
        yield db

    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()

app.dependency_overrides — FastAPI's dependency injection override. Replaces the real get_db (which connects to the dev database) with a function that yields the test database session.

ASGITransport(app=app) — calls the FastAPI app directly, in-process. No network, no server. httpx sends a request, Starlette routes it, your endpoint runs, and the response comes back — all in the same Python process.

Why httpx over TestClient?

FastAPI's TestClient uses requests (sync-only). For async endpoints with async database calls, you need an async test client. httpx provides AsyncClient with ASGITransport for direct ASGI app calls.

Writing a Test

@pytest.mark.asyncio                                    # Required with asyncio_mode = "strict"
async def test_health_returns_ok(client: AsyncClient) -> None:
    response = await client.get("/health")

    assert response.status_code == 200
    assert response.json() == {"status": "ok"}
  • @pytest.mark.asyncio — marks this as an async test. Without it, pytest would try to call the coroutine synchronously and the test would silently pass without running.
  • client — the AsyncClient fixture from conftest.py. pytest injects it automatically.