본문으로 건너뛰기
김신건의 로그

[FastAPI] Testing

· 수정 · 📖 약 2분 · 562자/단어 #python #fastapi #testing #pytest
FastAPI Testing, FastAPI TestClient, FastAPI pytest, httpx AsyncClient FastAPI, dependency_overrides, FastAPI 테스트

정의

FastAPI Testing 은 endpoint 를 실제 서버 없이 직접 호출해 응답을 검증하는 workflow 입니다. Starlette 의 TestClient (내부적으로 httpx sync) 또는 httpx.AsyncClient 로 앱 인스턴스를 감쌉니다.

TestClient 기본

from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()

@app.get("/hello")
def hello():
    return {"msg": "world"}

client = TestClient(app)

def test_hello():
    resp = client.get("/hello")
    assert resp.status_code == 200
    assert resp.json() == {"msg": "world"}

TestClient 는 프로세스 내부에서 ASGI 앱을 직접 호출. HTTP 서버 미기동. 매우 빠름.

pytest 통합

# conftest.py
import pytest
from fastapi.testclient import TestClient
from myapp.main import app

@pytest.fixture
def client():
    with TestClient(app) as c:
        yield c
# test_users.py
def test_read_user(client):
    resp = client.get("/users/1")
    assert resp.status_code == 200

def test_create_user(client):
    resp = client.post("/users/", json={"email": "a@b.com", "password": "secret123"})
    assert resp.status_code == 201
    data = resp.json()
    assert data["email"] == "a@b.com"

Async 테스트 (httpx.AsyncClient)

TestClient 는 sync. Async endpoint 를 async 스타일로 테스트하려면 httpx.AsyncClient.

import pytest
import httpx
from httpx import ASGITransport
from myapp.main import app

@pytest.mark.asyncio
async def test_read_user_async():
    async with httpx.AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as ac:
        resp = await ac.get("/users/1")
        assert resp.status_code == 200

pytest-asyncio 필요. asyncio_mode = "auto" 로 설정하면 @pytest.mark.asyncio 생략 가능.

# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"

dependency_overrides (핵심)

Endpoint 코드를 안 바꾸고 dependency 를 mock. 테스트 인프라의 심장.

from fastapi import Depends
from sqlalchemy.orm import Session

def get_db():
    db = SessionLocal()
    try: yield db
    finally: db.close()

@app.get("/users/{id}")
def read_user(id: int, db: Session = Depends(get_db)):
    return db.query(User).get(id)

테스트:

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

TEST_DB_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DB_URL, connect_args={"check_same_thread": False})
TestSessionLocal = sessionmaker(bind=engine)

def override_get_db():
    db = TestSessionLocal()
    try: yield db
    finally: db.close()

@pytest.fixture(autouse=True)
def setup_db():
    Base.metadata.create_all(engine)
    app.dependency_overrides[get_db] = override_get_db
    yield
    Base.metadata.drop_all(engine)
    app.dependency_overrides = {}

autouse=True 로 모든 테스트에 자동 적용. 테스트 후 override clear.

DB 테스트 패턴

패턴 1: SQLite in-memory

engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
  • Pros: 매우 빠름, 격리 완벽
  • Cons: PostgreSQL 특화 기능 (JSONB, LATERAL, …) 없음

패턴 2: Docker Postgres + fresh DB per test

@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine("postgresql://test:test@localhost/testdb")
    yield engine

@pytest.fixture(scope="function")
def db_session(db_engine):
    connection = db_engine.connect()
    transaction = connection.begin()
    Session = sessionmaker(bind=connection)
    session = Session()
    yield session
    session.close()
    transaction.rollback()   # 테스트 후 롤백
    connection.close()

Pros: 프로덕션 DB 와 동일. Transaction rollback 으로 격리. Cons: Docker 필요, 느림.

패턴 3: testcontainers

from testcontainers.postgres import PostgresContainer

@pytest.fixture(scope="session")
def postgres():
    with PostgresContainer("postgres:16") as p:
        yield p.get_connection_url()

Auth 테스트

@pytest.fixture
def authed_client():
    def override_current_user():
        return User(id=1, email="test@example.com", is_admin=True)

    app.dependency_overrides[get_current_user] = override_current_user

    with TestClient(app) as c:
        yield c

    app.dependency_overrides = {}

또는 실제 토큰 발급 후 헤더 전달:

@pytest.fixture
def token():
    return create_access_token({"sub": "user@example.com"})

def test_protected(client, token):
    resp = client.get("/me", headers={"Authorization": f"Bearer {token}"})
    assert resp.status_code == 200

파일 업로드 테스트

def test_upload(client):
    with open("tests/fixtures/sample.pdf", "rb") as f:
        resp = client.post(
            "/upload",
            files={"file": ("sample.pdf", f, "application/pdf")},
        )
    assert resp.status_code == 200

WebSocket 테스트

def test_websocket():
    with client.websocket_connect("/ws") as ws:
        ws.send_text("hello")
        data = ws.receive_text()
        assert data == "echo: hello"

Startup / Shutdown 이벤트

TestClient context manager (with TestClient(app) as c) 는 lifespan 이벤트를 실행.

@asynccontextmanager
async def lifespan(app: FastAPI):
    print("startup")
    yield
    print("shutdown")

app = FastAPI(lifespan=lifespan)

def test_lifespan():
    with TestClient(app) as client:
        client.get("/")   # startup 이미 실행
    # exit 시 shutdown

외부 API mocking (httpx)

httpx.AsyncClienthttpx_mock 또는 respx 로 mock.

import respx
from httpx import Response

@respx.mock
def test_external_call(client):
    respx.get("https://api.example.com/data").mock(
        return_value=Response(200, json={"key": "value"})
    )
    resp = client.get("/wrapped")
    assert resp.json()["proxied"] == "value"

Test isolation

각 테스트는 격리되어야 함. 안 지키면 flaky.

  • DB: transaction rollback 또는 truncate
  • Cache (Redis): FLUSHDB 또는 db=1
  • File system: tmp_path fixture
  • Global state: 반드시 avoidance (dependency injection 사용)

성능 테스트

로컬 부하 테스트:

pip install locust
locust -f locustfile.py --host http://localhost:8000
from locust import HttpUser, task

class WebsiteUser(HttpUser):
    @task
    def get_items(self):
        self.client.get("/items/")

    @task(3)   # 3배 자주
    def get_root(self):
        self.client.get("/")

Coverage

pytest --cov=myapp --cov-report=html --cov-report=term-missing

.coveragerc 로 branch coverage 등 세부 설정.

함정

WARNING

TestClient 안에서 request body 를 pydantic 검증하는 실제 endpoint 를 호출. Sync/async 미스매치 조심.

CAUTION

dependency_overrides clear 안 하면 다음 테스트로 leak. autouse=True fixture 로 자동 cleanup.

WARNING

DB session 을 fixture 로 재사용 시 lazy load 실패. SQLAlchemy 는 session close 후 relationship 접근 불가. expire_on_commit=False 또는 explicit refresh.

IMPORTANT

TestClient(app) 은 lifespan 을 실행하지 않는 사용 방식이 있음. with TestClient(app) as c 형태로 반드시 사용.

CAUTION

AsyncClient(transport=ASGITransport(app=app)) 처럼 명시적 transport 지정. base_url 도 정확히.

관련 위키

이 글의 용어 (7개)
[FastAPI] Async / Sync Endpointsfastapi
정의 FastAPI 는 ASGI 프레임워크 이므로 endpoint 를 로 정의해 이벤트 루프에서 실행하거나, 로 정의해 스레드풀에서 실행할 수 있습니다. 어느 쪽을 선택하는지가 …
[FastAPI] Dependency Injectionfastapi
정의 FastAPI Dependency Injection 은 로 함수를 endpoint 에 자동 주입하는 시스템입니다. DB 세션, 인증 사용자, 설정, 서비스 계층 등을 end…
[FastAPI] Routing (Path Operations)fastapi
정의 FastAPI Routing 은 HTTP method + URL 을 Python 함수 (path operation) 에 매핑하는 시스템입니다. Starlette 의 라우팅 …
[Python] asyncio: 이벤트 루프, async/awaitpython
정의 는 Python 표준 라이브러리의 단일 스레드 이벤트 루프 기반 비동기 I/O 프레임워크다. 로 코루틴을 정의하고 로 다른 코루틴/awaitable 완료를 기다린다. I/O…
[Python] FastAPIfastapi
정의 FastAPI 는 Python 3.8+ 을 위한 ASGI 기반 현대 웹/API 프레임워크 입니다. Sebastián Ramírez (tiangolo) 가 2018년 발표했고…
[Python] pytest: fixture, parametrize, mockpython
정의 는 Python 표준 테스트 프레임워크의 사실상 표준(stdlib는 이지만 pytest가 더 강력). 단순한 함수 로 시작해서 fixture, parametrize, plu…
[Python] unittest.mock: Mock, patch, MagicMockpython
정의 은 테스트용 대역체(test double) 생성과 패칭을 위한 표준 라이브러리. , , 를 통해 외부 의존성을 격리하고 호출을 검증한다. pytest와도 잘 어울리며 같은 …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기