테스트와 배포 — pytest, Docker, Railway/Fly
pytest + httpx로 FastAPI 통합 테스트, 의존성 오버라이드로 격리, Docker 멀티스테이지 빌드, Railway/Fly 클라우드 배포까지 정리합니다.
4부의 마지막 챕터는 테스트와 배포입니다. 만든 API가 정말 동작하는지 자동으로 검증하고, 컨테이너로 빌드해 클라우드에 올리는 흐름까지 함께 정리합니다.
본 챕터는 30장 타입체커 설정과 CI 통합, 31장 logging과 관측성과 한 묶음으로 운영됩니다. 본 챕터가 “테스트 + 배포의 표층”, 5부 챕터들이 “운영의 깊이"입니다. 그리고 29장 종합 실습 — TODO API 완성하기가 본 챕터까지의 모든 패턴을 한 서비스에 묶습니다.
pytest + httpx — FastAPI 테스트의 표준 #
FastAPI는 TestClient (httpx 기반)라는 in-process 테스트 클라이언트를 제공합니다. 실제 HTTP 서버를 띄우지 않고도 라우트를 호출할 수 있습니다.
uv add --dev pytest pytest-asyncio httpx첫 테스트 #
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}uv run pytest -vTestClient는 동기 인터페이스를 줍니다. 라우트가 async def 여도 그냥 호출.
비동기 테스트 — httpx.AsyncClient
#
비동기 픽스처 / 라이브러리를 직접 쓰려면 AsyncClient.
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest_asyncio.fixture
async def client() -> AsyncClient:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as c:
yield cimport pytest
@pytest.mark.asyncio
async def test_health(client):
response = await client.get("/health")
assert response.status_code == 200pytest.ini 또는 pyproject.toml에 비동기 모드 설정.
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]asyncio_mode = "auto"로 하면 모든 async def test_xxx가 자동으로 비동기로 실행됩니다 — @pytest.mark.asyncio 데코레이터 생략 가능.
의존성 오버라이드 — 외부 의존성 격리 #
테스트 때는 진짜 DB / 외부 API를 쓰면 느리고 변동성이 있습니다. 의존성 오버라이드가 표준 답입니다.
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.api.deps import get_session
from app.models.base import Base
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture
async def db_engine():
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(db_engine) -> AsyncSession:
async_session = async_sessionmaker(db_engine, expire_on_commit=False)
async with async_session() as session:
yield session
@pytest_asyncio.fixture
async def client(db_session):
async def override_get_session():
yield db_session
app.dependency_overrides[get_session] = override_get_session
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as c:
yield c
app.dependency_overrides.clear()핵심: app.dependency_overrides[원본] = 대체 한 줄이 25장에서 본 get_session 의존성을 테스트 버전으로 바꿉니다. 라우트 코드는 한 줄도 바뀌지 않습니다. 이게 의존성 주입의 가장 큰 효용입니다.
인증 우회 #
26장 인증의 get_current_user도 같은 방식으로:
from app.api.deps import get_current_user
from app.models.user import User
@pytest_asyncio.fixture
async def authed_client(client, db_session):
test_user = User(id=1, email="test@example.com", hashed_password="x")
db_session.add(test_user)
await db_session.commit()
async def override_user():
return test_user
app.dependency_overrides[get_current_user] = override_user
yield client테스트가 매번 토큰을 만들 필요 없이 인증된 상태로 시작.
라우트 테스트 패턴 #
import pytest
@pytest.mark.asyncio
async def test_create_todo(authed_client):
response = await authed_client.post(
"/todos/",
json={"title": "Pytest 학습"},
)
assert response.status_code == 201
data = response.json()
assert data["title"] == "Pytest 학습"
assert data["done"] is False
assert "id" in data
assert "created_at" in data
@pytest.mark.asyncio
async def test_create_todo_validates_title(authed_client):
response = await authed_client.post(
"/todos/",
json={"title": ""}, # min_length=1
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_get_todo_not_found(authed_client):
response = await authed_client.get("/todos/99999")
assert response.status_code == 404테스트 형태 — **AAA (Arrange / Act / Assert)**가 자연스러운 패턴:
- Arrange — 픽스처가 미리 준비
- Act —
client.post(...) - Assert — 응답 검증
흐름 테스트 #
여러 요청이 엮인 시나리오:
@pytest.mark.asyncio
async def test_full_todo_lifecycle(authed_client):
# Create
create_resp = await authed_client.post("/todos/", json={"title": "test"})
todo_id = create_resp.json()["id"]
# Get
get_resp = await authed_client.get(f"/todos/{todo_id}")
assert get_resp.json()["title"] == "test"
# Update
update_resp = await authed_client.patch(
f"/todos/{todo_id}",
json={"done": True},
)
assert update_resp.json()["done"] is True
# Delete
delete_resp = await authed_client.delete(f"/todos/{todo_id}")
assert delete_resp.status_code == 204
# Verify deleted
final_resp = await authed_client.get(f"/todos/{todo_id}")
assert final_resp.status_code == 404외부 호출 모킹 #
27장 비동기와 백그라운드 작업에서 본 httpx 외부 호출은 테스트에서 모킹하는 게 좋습니다.
uv add --dev pytest-httpximport pytest
@pytest.mark.asyncio
async def test_external_call(httpx_mock):
httpx_mock.add_response(
url="https://api.external.com/data",
json={"result": "ok"},
)
response = await my_function_that_calls_external()
assert response == "ok"pytest-httpx가 모든 httpx 요청을 가로채 미리 정한 응답을 줍니다. 외부 의존성이 없어 빠르고 안정적.
커버리지 #
uv add --dev coverage pytest-cov
uv run pytest --cov=app --cov-report=term-missingName Stmts Miss Cover Missing
----------------------------------------------------------
app/main.py 12 0 100%
app/api/todos.py 45 3 93% 78-80
app/services/user.py 22 1 95% 45
----------------------------------------------------------
TOTAL 79 4 95%100%가 목표가 아니라 중요한 경로가 검증되는지가 목표입니다. UI 테스트와 다르게 API는 커버리지가 비교적 잘 올라갑니다 — 80~90%가 무난한 수준.
통합 테스트 vs 단위 테스트 #
| 종류 | 대상 |
|---|---|
| 단위 테스트 | 순수 함수, 로직 — test_validate_email(), test_calculate_total() |
| 통합 테스트 | 라우트 + DB + 의존성 — test_create_todo() |
| E2E 테스트 | 실제 서버 + 실제 DB — 별도 환경 |
API 프로젝트는 통합 테스트가 주력입니다. 단위는 복잡한 비즈니스 로직에만 추가, E2E는 배포 전 smoke test.
Docker — 일관된 환경 #
배포할 때는 컨테이너로 묶어 어떤 환경에서도 같게 동작하게 합니다.
Dockerfile — 멀티스테이지 빌드 #
# === 1) 빌드 스테이지 — uv 로 의존성 설치 ===
FROM python:3.14-slim AS builder
# uv 설치
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
# 의존성만 먼저 복사 (캐시 효율)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
# 코드 복사
COPY app/ ./app/
# === 2) 런타임 스테이지 — 작은 이미지 ===
FROM python:3.14-slim AS runtime
# 비루트 사용자
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
# 빌더에서 venv 와 코드 복사
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/app /app/app
# venv 의 python 을 PATH 우선
ENV PATH="/app/.venv/bin:$PATH"
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]핵심 패턴:
- 멀티스테이지 — 빌드 도구는 최종 이미지에서 빠짐, 이미지 크기 작아짐
- 의존성 먼저, 코드 나중 — 코드만 바뀌면 의존성 레이어 캐시 재사용
- 비루트 사용자 — 보안 (root 권한 컨테이너는 위험)
--frozen—uv.lock그대로, 버전 결정성 보장--no-dev— 개발 의존성 제외
.dockerignore #
.venv/
__pycache__/
*.pyc
.pytest_cache/
.git/
.github/
tests/
.env
*.md이미지에 들어가지 않아도 되는 것들. 빌드 속도와 보안에 영향.
빌드와 실행 #
docker build -t todo-api:0.1 .
docker run --rm -p 8000:8000 \
-e DATABASE_URL=postgresql+asyncpg://... \
-e JWT_SECRET=... \
todo-api:0.1docker-compose — 로컬 개발 환경 #
API + DB + Redis 같이 띄울 때.
services:
api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://user:pw@db:5432/todo
JWT_SECRET: dev-secret
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pw
POSTGRES_DB: todo
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user"]
interval: 5s
timeout: 5s
retries: 5
volumes:
- pg-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
pg-data:docker compose up -dPostgres + Redis + API가 한 명령으로 뜨고, API는 DB가 ready가 될 때까지 기다립니다.
마이그레이션 — 배포 시점 자동화 #
25장의 Alembic 마이그레이션은 배포 시점에 자동으로 적용되어야 합니다.
옵션 1 — 컨테이너 시작 시 적용 #
#!/usr/bin/env bash
set -e
alembic upgrade head
exec "$@"COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]간단하지만 여러 인스턴스가 동시에 적용을 시도하면 충돌이 가능. 작은 트래픽에서만.
옵션 2 — CI/CD 단계에서 별도 #
배포 파이프라인에서 마이그레이션을 명시적인 단계로 분리. 한 번만 실행되고, 실패하면 배포 자체가 중단되도록. 30장 타입체커 설정과 CI 통합의 GitHub Actions 패턴과 같이 다뤄집니다.
- name: Run migrations
run: |
uv run alembic upgrade head
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Deploy
run: |
flyctl deploy --remote-only큰 프로젝트는 옵션 2가 안전합니다.
클라우드 배포 — 가벼운 옵션 두 가지 #
Railway #
GitHub 저장소 연결만으로 자동 배포가 동작하는 가장 가벼운 옵션.
- railway.app 가입
- “New Project” → “Deploy from GitHub”
- 저장소 선택, Postgres / Redis 추가
- 환경 변수 설정 (
JWT_SECRET,DATABASE_URL등) - 자동 배포
railway.json으로 빌드 / 실행 명령 커스터마이즈 가능.
{
"build": {
"builder": "DOCKERFILE"
},
"deploy": {
"startCommand": "uvicorn app.main:app --host 0.0.0.0 --port $PORT",
"healthcheckPath": "/health",
"restartPolicyType": "ON_FAILURE"
}
}Fly.io #
여러 리전 배포가 쉬운 옵션. CLI 도구 (flyctl)가 있습니다.
brew install flyctl # macOS
flyctl auth login
flyctl launch # 자동으로 fly.toml 생성
flyctl deploy
flyctl secrets set JWT_SECRET=...app = "todo-api"
primary_region = "nrt"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = true
auto_start_machines = true
[checks]
[checks.health]
type = "http"
path = "/health"
interval = "30s"
timeout = "5s"비교:
| Railway | Fly.io | |
|---|---|---|
| 학습 곡선 | 매우 평탄 | 평탄 |
| 가격 | 사용량 기반 | 사용량 + 무료 티어 |
| 글로벌 분산 | 단순 | 강함 (여러 리전) |
| 어울리는 경우 | 빠른 프로토타입 | 글로벌 서비스 |
프로덕션 체크리스트 #
배포 전 확인:
-
JWT_SECRET— 64바이트+ 랜덤 -
DEBUG=False또는--debug미사용 - HTTPS — 클라우드 제공자가 자동 처리하지만 강제 리다이렉트 확인
- CORS —
allow_origins=["*"]가 아닌 명시적 도메인 - DB 비밀번호 — 추측 불가
- 로그 레벨 — 프로덕션은 INFO 이상 (31장 logging과 관측성)
- 에러 추적 — Sentry 같은 도구 통합 (31장)
- 헬스 체크 —
/health엔드포인트 - 마이그레이션 자동화 — 배포 파이프라인에 포함
- 백업 — DB 자동 백업 활성
- 모니터링 — 응답 시간, 에러율 대시보드
- rate limiting — DDoS / abuse 방지
본 체크리스트는 시작점입니다. 트래픽과 요구사항에 따라 추가됩니다.
연습문제 #
TestClient와AsyncClient + ASGITransport두 방식으로GET /health테스트를 모두 작성하세요.asyncio_mode = "auto"설정 후 동기 / 비동기 테스트가 한 디렉터리에 섞여 있어도 정상 실행되는지 확인합니다.app.dependency_overrides[get_session] = override패턴으로 in-memory SQLite를 테스트 DB로 쓰는client픽스처를 작성하세요.POST /todos→GET /todos/{id}→DELETE흐름 테스트를 작성하고pytest -v로 통과를 확인합니다.- Dockerfile 멀티스테이지 빌드를 작성하고
docker build -t todo-api:0.1 .로 빌드합니다. 빌드된 이미지를docker run하고curl http://localhost:8000/health가 응답하는지 확인합니다.docker images로 최종 이미지 크기를 확인하고, slim 베이스 vs distroless 같은 옵션이 크기에 어떤 차이를 만드는지 직접 비교해 보겠습니다.
한 줄 요약:
TestClient(동기) /AsyncClient+ASGITransport(비동기),asyncio_mode = "auto"로 통일.app.dependency_overrides로 DB / 인증 / 외부 호출 격리. CRUD 흐름 테스트,pytest-httpx외부 모킹, 커버리지는 100%가 아니라 핵심 경로. Docker 멀티스테이지 +.dockerignore+ 비루트, docker-compose로 로컬 풀스택. 마이그레이션은 CI/CD 단계로 분리하는 편이 안전. Railway (가벼움) vs Fly.io (글로벌). 프로덕션 체크리스트는 시크릿 / HTTPS / CORS / 로그 / 백업 / 모니터링.
다음 챕터 #
다음 29장 종합 실습 — TODO API 완성하기 ★신규 챕터에서 22~28장의 모든 패턴을 하나의 동작하는 서비스로 엮습니다. 그 다음 5부 30장 타입체커 설정과 CI 통합부터 운영 영역으로 진입합니다.