Lifespan and Testing
FastAPI 서비스는 "요청 처리 코드"만 맞아서는 충분하지 않다. DB engine, HTTP client, cache client, settings, background task runner 같은 자원을 언제 열고 언제 닫는지까지 설계해야 production과 test가 같은 모델로 움직인다.
빠른 요약: startup/shutdown 이벤트를 흩뿌리기보다 lifespan에서 자원 소유권을 명확히 두고, 테스트는 같은 lifespan 경계를 통과하도록 만드는 편이 가장 일관된다. 테스트에서 dependency override와 transaction 격리를 같이 설계해야 "실제 서비스와 비슷한 테스트"가 된다.
먼저 그림으로 잡기
Lifespan은 무엇을 소유해야 하나
lifespan에 두기 좋은 것
- SQLAlchemy engine 또는 async engine
httpx.AsyncClient같은 프로세스 공유 클라이언트- Redis client, message broker producer
- warm-up이 필요한 설정/모델/캐시
request dependency에 두기 좋은 것
Session/AsyncSession- 현재 사용자 principal
- 요청 단위 trace context
- 요청 단위 서비스 인스턴스
권장 패턴: app state + dependency 분리
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
engine = create_async_engine(
"postgresql+asyncpg://app:secret@localhost/app",
pool_pre_ping=True,
)
app.state.session_factory = async_sessionmaker(
engine,
autoflush=False,
expire_on_commit=False,
)
try:
yield
finally:
await engine.dispose()
app = FastAPI(lifespan=lifespan)
async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
session_factory: async_sessionmaker[AsyncSession] = request.app.state.session_factory
async with session_factory() as session:
yield sessionengine는 process-wide 자원이라 lifespan에서 열고 닫는다. 반면 `AsyncSession`은 request마다 새로 만들어야 한다. app state는 공유 자원을 보관하는 곳이지, request마다 mutate되는 비즈니스 상태를 넣는 곳이 아니다.
테스트에서도 lifespan을 통과시켜야 하는 이유
TestClient(app) 또는 ASGI transport 기반 통합 테스트를 context manager로 열면 lifespan이 같이 실행된다. 이 과정을 생략하면 production에서는 생성되는 자원이 테스트에서는 비어 있어서, 테스트가 잘못된 전제 위에서 돌아갈 수 있다.
from fastapi.testclient import TestClient
def test_healthcheck() -> None:
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200dependency override는 "mock 주입"보다 boundary 검증 도구다
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def get_test_session() -> AsyncIterator[AsyncSession]:
async with TestSessionFactory() as session:
yield session
app.dependency_overrides[get_session] = get_test_session- unit test에서는 fake service를 주입해 route contract를 빠르게 검증할 수 있다.
- integration test에서는 실제 DB 세션 factory를 주입하되 rollback 전략을 붙일 수 있다.
- override는 테스트가 끝나면 원래 dependency로 복원해야 한다.
비동기 통합 테스트 기본 패턴
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.mark.anyio
async def test_register_user() -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
response = await client.post(
"/users",
json={"email": "neo@example.com", "name": "Neo"},
)
assert response.status_code == 201
assert response.json()["email"] == "neo@example.com"DB 테스트를 설계할 때 체크할 것
| 질문 | 권장 방향 | 피해야 할 패턴 |
|---|---|---|
| 테스트마다 DB를 어떻게 초기화하나 | fixture에서 schema 준비 또는 transaction rollback | 각 테스트가 스스로 migration을 반복 |
| 세션 격리는 어떻게 보장하나 | SAVEPOINT, 별도 test DB, deterministic seed | global session 재사용 |
| 서비스는 무엇을 검증하나 | status code뿐 아니라 response shape와 side effect | route 함수 내부 구현 세부사항 |
| 외부 API는 어떻게 대체하나 | override된 client/provider 주입 | 실제 production endpoint 호출 |
fixture와 teardown은 테스트 바깥에서 소유한다
yield fixture가 setup/teardown 소유권을 가장 읽기 쉽게 드러낸다.app.dependency_overrides는 fixture teardown에서 반드시 비워야 한다.TestClient도 fixture 안 context manager로 열고 닫는 편이 안정적이다.- 더 긴 예제는 Testing with Fixtures와
tests/test_fastapi_fixtures_and_teardown.py를 같이 보면 된다.
흔한 실수
- startup에서 session을 만들어 전역으로 재사용한다.
- 테스트가 lifespan을 거치지 않아 app state 초기화가 빠진다.
dependency_overrides를 테스트 후 정리하지 않는다.- 비동기 테스트에서 sync client, sync fixture, event loop lifecycle이 뒤섞인다.
실전 체크리스트
공유 자원은 lifespan
engine, HTTP client, cache client처럼 process 범위 자원은 lifespan에 둬야 열고 닫는 위치가 명확하다.
요청 자원은 dependency
세션과 현재 사용자 같은 request scope 정보는 `yield` dependency로 관리하는 편이 가장 읽기 쉽다.
테스트도 같은 경계 사용
lifespan, dependency override, DB rollback 전략을 production과 최대한 같은 구조 위에 올린다.
외부 의존성은 교체 가능해야 함
서비스가 구체 구현을 직접 만들지 않고 provider로 받도록 설계해야 테스트 override가 쉬워진다.