[FastAPI 시리즈 15편] pytest와 httpx로 API 테스트하기

English version

pytest와 httpx로 API 테스트하기

테스트는 서비스 신뢰도를 지키는 가장 빠른 피드백 루프입니다. pytest는 Python 진영에서 널리 쓰이는 단위 테스트 프레임워크이고, httpx는 비동기 HTTP 클라이언트입니다. 이번 편에서는 FastAPI가 제공하는 TestClient, 그리고 비동기 호출을 자연스럽게 다룰 수 있는 httpx.AsyncClient를 사용해 API를 검증합니다.

이번 글에서 새로 나오는 용어

  1. pytest: Python에서 가장 많이 쓰이는 테스트 러너로, 함수 기반 테스트와 풍부한 플러그인을 지원해 FastAPI 검증에 적합합니다.
  2. httpx.AsyncClient: 비동기 HTTP 요청을 보낼 수 있는 클라이언트로, FastAPI의 async 라우터를 자연스럽게 테스트하는 데 사용합니다.
  3. 픽스처(fixture): pytest가 테스트 전에 실행해 공통 자원을 제공하는 함수로, 여기서는 client나 가짜 사용자 같은 의존성을 준비합니다.
  4. @pytest.mark.asyncio: 비동기 테스트 함수가 이벤트 루프에서 실행되도록 표시하는 데코레이터로, httpx AsyncClient 테스트에 필수입니다.

실습 카드

  • 예상 소요 시간: 50분
  • 사전 준비: 12편 미니 서비스, pytest 설치
  • 실습 목표: httpx.AsyncClient와 dependency_overrides로 FastAPI 엔드포인트를 테스트한다

테스트 환경 준비

pytest, httpx, pytest-asyncio를 설치합니다.

pip install pytest httpx pytest-asyncio

conftest.py에서 앱과 의존성 오버라이드를 정의하면 각 테스트에서 반복 설정을 줄일 수 있습니다.

# 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은 테스트 전용 SQLite 메모리 DB를 제공한다고 가정합니다. 이런 스텁 덕분에 실제 DB를 띄우지 않고도 빠르게 실패를 재현할 수 있습니다.

동기 TestClient vs httpx.AsyncClient

  • fastapi.testclient.TestClient는 내부적으로 requests를 사용해 동기 코드 기반 테스트를 빠르게 작성할 수 있습니다.
  • 비동기 라우터나 async 의존성이 많은 경우 httpx.AsyncClient가 자연스럽습니다.

실습에서는 httpx를 사용합니다. 비동기 경로가 많은 FastAPI 앱일수록 이벤트 루프와 자연스럽게 맞물리기 때문입니다.

CRUD 테스트 예시

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

@pytest.mark.asyncio는 비동기 테스트 함수를 실행할 수 있게 이벤트 루프를 구성합니다. 이 마커를 빼먹으면 RuntimeError: no running event loop가 발생하니 주의합니다.

인증 흐름 스텁

테스트마다 로그인 과정을 반복하지 않기 위해 의존성을 스텁으로 대체합니다.

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

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

이렇게 하면 JWT 발급 로직을 통과하지 않아도 인증된 사용자로 요청을 보낼 수 있습니다. 인증 흐름을 한 번만 테스트하고, 나머지 테스트에서는 스텁으로 속도를 확보합니다.

httpx LiveServer 테스트

httpx.AsyncClient(app=app) 방식은 ASGI 앱을 메모리 내부에서 호출합니다. uvicorn으로 띄운 실제 포트를 테스트하려면 async with httpx.AsyncClient(base_url="http://127.0.0.1:8000")처럼 별도 서버를 대상으로 요청합니다. 이 경우 테스트 전에 서버 실행을 보장해야 하므로 CI에서는 권장되지 않습니다.

커버리지와 구조

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

위 구조처럼 테스트가 직접적으로 DB를 호출하는 대신 DB Stub을 거칩니다. 실제 DB 드라이버 이슈와 분리된 빠른 검증이 가능합니다. 필요 시 일부 통합 테스트에서만 실제 DB를 사용합니다.

명령어 모음

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

--maxfail=1은 첫 번째 실패 후 즉시 멈춰 디버깅 시간을 줄입니다. 자주 쓰는 명령을 스크립트에 넣어 두면 팀원과 같은 기준으로 테스트를 돌릴 수 있습니다.

기대 출력 확인

테스트 글에서는 성공 화면도 예제의 일부입니다.

:::terminal{title="pytest 실행 결과 예시", 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 }
]

:::

  • 확인할 점: 어떤 테스트 파일이 통과했는지 보이는지
  • 확인할 점: 마지막 줄에 passed 개수와 시간이 있는지
  • 확인할 점: 실패가 나면 어느 테스트가 깨졌는지 바로 찾을 수 있는지

테스트 습관을 들이면 나중에 설정, 보안, 성능 최적화 같은 민감한 변경도 자신 있게 진행할 수 있습니다. 다음 편에서는 CORS와 기본 보안 설정을 손봅니다.

실습

  • 따라 하기: client 픽스처와 CRUD 테스트 하나를 만들어 pytest -q로 통과시킨다.
  • 확장하기: 인증 스텁을 추가해 보호된 라우터를 테스트하고 실패 케이스(400/404)도 검증한다.
  • 디버깅: TestClient와 AsyncClient 선택 기준을 비교하고 이벤트 루프 충돌 시 pytest-asyncio 설정을 조정한다.
  • 완료 기준: 최소 3개의 테스트가 통과하고 실패 시 로그로 원인을 바로 확인할 수 있다.

마무리

테스트는 작은 FastAPI 서비스에도 필수입니다. pytest와 httpx로 빠르게 실패를 재현하는 루틴을 만들어 두면 이후 설정 변경이나 인프라 이전도 훨씬 안전해집니다.

💬 댓글

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