[FastAPI Series 15] How to Test Your FastAPI App with pytest and httpx

한국어 버전

Testing is the fastest feedback loop for service reliability. pytest dominates Python testing, and httpx provides an async HTTP client. This post uses FastAPI’s TestClient heritage plus httpx.AsyncClient so async routes feel natural.

Key terms

  1. pytest: The de facto Python test runner with function-based tests and rich plugins, ideal for FastAPI.
  2. httpx.AsyncClient: An async HTTP client perfect for hitting FastAPI routes in event-loop-friendly tests.
  3. Fixture: A pytest function that prepares shared resources such as clients or fake users before each test.
  4. @pytest.mark.asyncio: A decorator that lets pytest run async test functions on an event loop.

Practice card

  • Estimated time: 50 minutes
  • Prereqs: Mini service from Part 12, pytest installed
  • Goal: Test FastAPI endpoints with httpx.AsyncClient and dependency overrides

Prepare the test environment

Install the base packages:

pip install pytest httpx pytest-asyncio

Define the app and dependency overrides in conftest.py so every test gets the same wiring:

# tests/conftest.py
from httpx import AsyncClient

from app.main import app
from app.db.session import override_session

@pytest.fixture(autouse=True)
def override_dependencies():
    app.dependency_overrides[get_session] = override_session
    yield
    app.dependency_overrides.clear()

@pytest.fixture
async def client():
    async with AsyncClient(app=app, base_url="http://test") as ac:
        yield ac

override_session supplies an in-memory SQLite session for tests. With that stub you avoid spinning up the real DB.

TestClient vs httpx.AsyncClient

  • fastapi.testclient.TestClient wraps requests for quick synchronous tests.
  • When routes and dependencies are async-heavy, httpx.AsyncClient keeps the code natural.

The exercises here use httpx because the series increasingly leans on async endpoints.

CRUD test example

@pytest.mark.asyncio
async def test_create_task(client):
    payload = {"title": "First task"}
    response = await client.post("/tasks", json=payload)
    assert response.status_code == 201
    data = response.json()
    assert data["title"] == "First task"
    assert data["done"] is False

@pytest.mark.asyncio sets up the event loop. Skipping it triggers RuntimeError: no running event loop.

Stub the auth flow

Avoid logging in for every test by overriding the dependency that returns the current user:

@pytest.fixture
    return User(id=1, email="[email protected]")

@pytest.fixture(autouse=True)
    app.dependency_overrides[get_current_user] = lambda: fake_user
    yield

Test the authentication pipeline once, then rely on stubs elsewhere to stay fast.

httpx against live servers

AsyncClient(app=app) calls the ASGI app in-process. If you need to hit a running uvicorn instance, open the client with base_url="http://127.0.0.1:8000" and ensure the server is already running. CI rarely uses this because it adds another moving piece.

Coverage structure

Test Suite --> (API Layer)
Test Suite --> (Service Layer)
Service Layer --> (DB Stub)

The suite talks to the API and service layers but hits a stubbed DB. Reserve real DB calls for a handful of integration tests.

Command cheat sheet

pytest -q
pytest --maxfail=1 --disable-warnings -q
pytest --cov=app --cov-report=term-missing

--maxfail=1 stops on the first failure so you can debug faster. Add your go-to commands to package scripts so the team shares one standard.

Expected output

Successful test runs should look like this:

:::terminal{title="pytest run", showFinalPrompt="false"}

[
  { "cmd": "uv run pytest -q", "output": "tests/test_auth.py ..\ntests/test_tasks.py ...\ntests/test_health.py .\n6 passed in 0.84s", "delay": 500 }
]

:::

  • Checkpoint: the log shows which files passed.
  • Checkpoint: the last line reports the pass count and duration.
  • Checkpoint: when failures occur you can pinpoint the exact test.

Practice

  • Follow along: write a client fixture and a CRUD test, then run pytest -q.
  • Extend: add an auth stub to exercise protected routes plus 400/404 cases.
  • Debug: compare TestClient vs AsyncClient behavior and adjust pytest-asyncio when event loop conflicts appear.
  • Done when: at least three tests pass and failure logs clearly highlight the root cause.

Wrap-up

Even small FastAPI services need tests. Once pytest and httpx become muscle memory you can refactor settings, move infrastructure, or ship new features without fear.

💬 댓글

이 글에 대한 의견을 남겨주세요