FastAPI 테스트 - 단위 테스트, 통합 테스트, E2E 테스트, TDD
pytest로 FastAPI 애플리케이션의 단위 테스트, 통합 테스트, E2E 테스트를 작성하는 방법과 TDD 개발 방식을 이해한다.
왜 테스트를 작성하는가
코드를 수정할 때마다 직접 API를 호출해서 결과를 눈으로 확인하는 방식은 한계가 있다.
- 기능이 늘어날수록 확인해야 할 케이스가 기하급수적으로 증가한다
- "이 부분을 고쳤더니 저 부분이 깨졌다"는 상황을 코드 배포 전에 잡지 못한다
- 팀원이 코드를 이해할 때 테스트가 살아있는 문서 역할을 한다
테스트는 코드 품질을 유지하는 안전망이다.
테스트의 종류
E2E 테스트 실제 브라우저/HTTP 클라이언트로 전체 흐름 검증
▲
통합 테스트 여러 계층(라우터 + 서비스 + DB)을 묶어서 검증
▲
단위 테스트 함수/클래스 하나를 독립적으로 검증
| 구분 | 범위 | 속도 | 신뢰도 |
|---|---|---|---|
| 단위 테스트 | 함수 하나 | 매우 빠름 | 부분적 |
| 통합 테스트 | 라우터 + DB | 중간 | 높음 |
| E2E 테스트 | 전체 시스템 | 느림 | 가장 높음 |
Spring Boot로 치면 @SpringBootTest, @WebMvcTest, JUnit5와 같은 역할을 FastAPI에서는 pytest + TestClient가 담당한다.
설치
pip install pytest pytest-asyncio httpx
pytest— 파이썬 표준 테스트 프레임워크pytest-asyncio— async 함수 테스트 지원httpx— FastAPI TestClient가 내부적으로 사용하는 HTTP 클라이언트
테스트할 앱 구조
이전 편에서 만든 유저 API를 기반으로 테스트를 작성한다.
app/
├── main.py
├── routers/
│ └── user_router.py
├── services/
│ └── user_service.py
├── repositories/
│ └── user_repository.py
├── models.py
└── schemas.py
tests/
├── unit/
│ └── test_user_service.py
├── integration/
│ └── test_user_router.py
└── e2e/
└── test_user_flow.py
1. 단위 테스트 (Unit Test)
단위 테스트는 외부 의존성 없이 함수 하나만 테스트한다. DB, 네트워크, 외부 서비스를 실제로 사용하지 않고 Mock으로 대체한다.
서비스 계층 테스트
# app/services/user_service.py
from app.repositories.user_repository import UserRepository
from app.schemas import UserCreate, UserResponse
class UserService:
def __init__(self, repo: UserRepository):
self.repo = repo
def create_user(self, data: UserCreate) -> UserResponse:
if self.repo.find_by_email(data.email):
raise ValueError("이미 존재하는 이메일입니다")
return self.repo.save(data)
def get_user(self, user_id: int) -> UserResponse:
user = self.repo.find_by_id(user_id)
if not user:
raise ValueError(f"유저 {user_id}를 찾을 수 없습니다")
return user
# tests/unit/test_user_service.py
import pytest
from unittest.mock import MagicMock
from app.services.user_service import UserService
from app.schemas import UserCreate, UserResponse
@pytest.fixture
def mock_repo():
return MagicMock()
@pytest.fixture
def service(mock_repo):
return UserService(repo=mock_repo)
def test_create_user_성공(service, mock_repo):
# Given: 이메일 중복 없음
mock_repo.find_by_email.return_value = None
mock_repo.save.return_value = UserResponse(id=1, name="홍길동", email="hong@test.com")
data = UserCreate(name="홍길동", email="hong@test.com")
# When
result = service.create_user(data)
# Then
assert result.id == 1
assert result.email == "hong@test.com"
mock_repo.save.assert_called_once_with(data)
def test_create_user_이메일_중복(service, mock_repo):
# Given: 이미 존재하는 이메일
mock_repo.find_by_email.return_value = UserResponse(id=1, name="기존유저", email="hong@test.com")
data = UserCreate(name="홍길동", email="hong@test.com")
# When / Then
with pytest.raises(ValueError, match="이미 존재하는 이메일"):
service.create_user(data)
def test_get_user_존재하지_않음(service, mock_repo):
# Given
mock_repo.find_by_id.return_value = None
# When / Then
with pytest.raises(ValueError, match="찾을 수 없습니다"):
service.get_user(999)
핵심 개념: Given / When / Then
def test_무언가():
# Given: 사전 조건 준비
mock_repo.find_by_email.return_value = None
# When: 실제로 테스트할 동작
result = service.create_user(data)
# Then: 결과 검증
assert result.id == 1
Spring의 JUnit과 동일한 패턴이다. 테스트의 의도를 명확히 드러낸다.
Mock이란?
mock_repo = MagicMock()
# 특정 메서드가 특정 값을 리턴하도록 설정
mock_repo.find_by_email.return_value = None
# 해당 메서드가 실제로 호출됐는지 검증
mock_repo.save.assert_called_once_with(data)
실제 DB를 붙이지 않고 "DB가 이런 값을 반환했다"고 가정하고 서비스 로직만 검증한다.
2. 통합 테스트 (Integration Test)
통합 테스트는 라우터 + 서비스 + DB를 묶어서 검증한다. FastAPI의 TestClient를 사용하면 실제 HTTP 요청을 보내는 것처럼 테스트할 수 있다.
TestClient 기본 사용법
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_루트():
response = client.get("/")
assert response.status_code == 200
TestClient는 실제 서버를 띄우지 않는다. 내부적으로 HTTP 요청을 흉내내어 FastAPI 앱에 직접 전달한다.
테스트용 DB 분리
통합 테스트에서는 실제 운영 DB를 건드리면 안 된다. 테스트 전용 DB를 따로 쓴다.
# tests/conftest.py ← pytest가 자동으로 읽는 설정 파일
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.main import app
from app.database import Base, get_db
# 테스트용 인메모리 SQLite DB
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(bind=engine)
@pytest.fixture(autouse=True)
def setup_db():
"""각 테스트 전에 테이블 생성, 후에 삭제"""
Base.metadata.create_all(bind=engine)
yield
Base.metadata.drop_all(bind=engine)
@pytest.fixture
def db():
session = TestingSessionLocal()
try:
yield session
finally:
session.close()
@pytest.fixture
def client(db):
"""운영 DB 대신 테스트 DB를 주입한 TestClient"""
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
conftest.py는 pytest의 전역 fixture 파일이다. Spring의 @TestConfiguration과 비슷한 역할이다.
라우터 통합 테스트
# tests/integration/test_user_router.py
import pytest
def test_유저_생성(client):
# When
response = client.post("/users", json={
"name": "홍길동",
"email": "hong@test.com"
})
# Then
assert response.status_code == 201
data = response.json()
assert data["name"] == "홍길동"
assert data["email"] == "hong@test.com"
assert "id" in data
def test_유저_생성_이메일_중복(client):
# Given: 이미 같은 이메일로 유저 생성
client.post("/users", json={"name": "기존유저", "email": "hong@test.com"})
# When: 같은 이메일로 다시 생성 시도
response = client.post("/users", json={"name": "홍길동", "email": "hong@test.com"})
# Then
assert response.status_code == 409
def test_유저_조회(client):
# Given
created = client.post("/users", json={"name": "홍길동", "email": "hong@test.com"}).json()
user_id = created["id"]
# When
response = client.get(f"/users/{user_id}")
# Then
assert response.status_code == 200
assert response.json()["name"] == "홍길동"
def test_유저_조회_없는_ID(client):
response = client.get("/users/999")
assert response.status_code == 404
def test_유저_목록(client):
# Given
client.post("/users", json={"name": "유저1", "email": "user1@test.com"})
client.post("/users", json={"name": "유저2", "email": "user2@test.com"})
# When
response = client.get("/users")
# Then
assert response.status_code == 200
assert len(response.json()) == 2
def test_유저_삭제(client):
# Given
created = client.post("/users", json={"name": "홍길동", "email": "hong@test.com"}).json()
user_id = created["id"]
# When
response = client.delete(f"/users/{user_id}")
# Then
assert response.status_code == 204
assert client.get(f"/users/{user_id}").status_code == 404
통합 테스트는 실제 DB에 데이터를 넣고 빼면서 전체 흐름을 검증한다. Mock을 쓰지 않으니 실제 동작에 가깝다.
3. E2E 테스트 (End-to-End Test)
E2E 테스트는 실제 서버를 띄우고 실제 HTTP 요청을 보내서 사용자 시나리오 전체를 검증한다.
pytest-asyncio로 비동기 E2E 테스트
pip install pytest-asyncio httpx
# tests/e2e/test_user_flow.py
import pytest
import httpx
BASE_URL = "http://localhost:8000"
@pytest.mark.asyncio
async def test_유저_전체_CRUD_시나리오():
async with httpx.AsyncClient(base_url=BASE_URL) as client:
# 1. 유저 생성
create_resp = await client.post("/users", json={
"name": "홍길동",
"email": "hong@test.com"
})
assert create_resp.status_code == 201
user_id = create_resp.json()["id"]
# 2. 생성된 유저 조회
get_resp = await client.get(f"/users/{user_id}")
assert get_resp.status_code == 200
assert get_resp.json()["name"] == "홍길동"
# 3. 유저 목록에 포함됐는지 확인
list_resp = await client.get("/users")
assert any(u["id"] == user_id for u in list_resp.json())
# 4. 유저 삭제
delete_resp = await client.delete(f"/users/{user_id}")
assert delete_resp.status_code == 204
# 5. 삭제 후 조회 시 404
not_found_resp = await client.get(f"/users/{user_id}")
assert not_found_resp.status_code == 404
E2E 테스트는 실제 서버가 실행 중이어야 한다. CI/CD 파이프라인에서는 테스트 서버를 자동으로 띄운 후 실행한다.
conftest.py로 테스트 서버 자동 시작
# tests/e2e/conftest.py
import pytest
import subprocess
import time
import httpx
@pytest.fixture(scope="session", autouse=True)
def start_server():
"""E2E 테스트 전에 서버를 자동으로 시작"""
proc = subprocess.Popen(["uvicorn", "app.main:app", "--port", "8001"])
time.sleep(2) # 서버 기동 대기
yield
proc.terminate()
3-1. Depends 의존성 테스트
실제 FastAPI 앱에서는 Depends()가 곳곳에 쓰인다. 테스트에서는 이 의존성을 override해서 실제 DB, 실제 인증 서버 없이도 테스트할 수 있다.
dependency_overrides 원리
# 운영 코드
@router.get("/users/me")
def get_me(current_user: User = Depends(get_current_user)):
return current_user
# 테스트에서 get_current_user를 가짜로 교체
app.dependency_overrides[get_current_user] = lambda: fake_user
app.dependency_overrides는 딕셔너리다. 키는 원래 의존성 함수, 값은 대체할 함수. 테스트가 끝나면 반드시 clear()해야 한다.
인증 의존성 override
JWT 토큰을 검증하는 get_current_user를 테스트에서 교체하는 예시다.
# app/dependencies/auth.py
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from app.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
# 실제로는 JWT 검증 후 유저 반환
user = verify_token(token)
if not user:
raise HTTPException(status_code=401, detail="인증 실패")
return user
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies.auth import get_current_user
from app.models import User
@pytest.fixture
def fake_user():
return User(id=1, name="테스트유저", email="test@test.com", role="USER")
@pytest.fixture
def fake_admin():
return User(id=2, name="관리자", email="admin@test.com", role="ADMIN")
@pytest.fixture
def authenticated_client(fake_user):
"""인증된 일반 유저로 요청하는 TestClient"""
app.dependency_overrides[get_current_user] = lambda: fake_user
yield TestClient(app)
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(fake_admin):
"""관리자 권한으로 요청하는 TestClient"""
app.dependency_overrides[get_current_user] = lambda: fake_admin
yield TestClient(app)
app.dependency_overrides.clear()
# tests/integration/test_auth_endpoints.py
def test_내_정보_조회_인증됨(authenticated_client, fake_user):
response = authenticated_client.get("/users/me")
assert response.status_code == 200
assert response.json()["email"] == fake_user.email
def test_내_정보_조회_미인증():
# override 없이 그냥 TestClient 사용 → 실제 인증 로직 실행 → 401
client = TestClient(app)
response = client.get("/users/me")
assert response.status_code == 401
def test_관리자_전용_엔드포인트(admin_client):
response = admin_client.get("/admin/users")
assert response.status_code == 200
def test_일반유저_관리자_엔드포인트_접근_거부(authenticated_client):
response = authenticated_client.get("/admin/users")
assert response.status_code == 403
실제 JWT 토큰 없이도 "인증된 상태"를 만들어 테스트할 수 있다.
서비스 의존성 override
DB 없이 서비스 레이어를 Mock으로 대체해서 라우터만 테스트한다.
# app/routers/user_router.py
from app.services.user_service import UserService, get_user_service
@router.get("/users/{user_id}")
def get_user(
user_id: int,
service: UserService = Depends(get_user_service)
):
return service.get_user(user_id)
# tests/integration/test_user_router.py
from unittest.mock import MagicMock
from app.services.user_service import get_user_service
from app.schemas import UserResponse
def test_유저_조회_서비스_Mock(client): # 여기서 client는 DB가 주입된 fixture
# 서비스 자체를 Mock으로 교체
mock_service = MagicMock()
mock_service.get_user.return_value = UserResponse(
id=1, name="홍길동", email="hong@test.com"
)
app.dependency_overrides[get_user_service] = lambda: mock_service
response = TestClient(app).get("/users/1")
assert response.status_code == 200
assert response.json()["name"] == "홍길동"
mock_service.get_user.assert_called_once_with(1)
app.dependency_overrides.clear()
여러 의존성 동시 override
@pytest.fixture
def client_with_all_mocks(fake_user):
"""DB, 인증, 외부 API 전부 Mock으로 교체"""
mock_db = MagicMock()
mock_external = MagicMock()
mock_external.fetch.return_value = {"data": "mock"}
app.dependency_overrides[get_db] = lambda: mock_db
app.dependency_overrides[get_current_user] = lambda: fake_user
app.dependency_overrides[get_external_api] = lambda: mock_external
yield TestClient(app)
app.dependency_overrides.clear() # 반드시 정리
conftest.py에서 자동 정리
여러 테스트에서 override를 쓰다 보면 clear() 빠뜨리기 쉽다. autouse fixture로 자동화한다.
# tests/conftest.py
@pytest.fixture(autouse=True)
def clear_dependency_overrides():
"""모든 테스트 후 의존성 override 초기화"""
yield
app.dependency_overrides.clear()
이렇게 하면 각 테스트에서 clear()를 직접 호출하지 않아도 된다.
4. TDD (Test Driven Development)
TDD는 테스트를 먼저 작성하고, 그 테스트를 통과시키는 코드를 나중에 작성하는 개발 방식이다.
Red → 실패하는 테스트 작성
Green → 테스트가 통과하는 최소한의 코드 작성
Refactor → 코드 정리
TDD 실전 예시: 비밀번호 검증 기능
Step 1 - Red: 실패하는 테스트 먼저 작성
# tests/unit/test_password_validator.py
import pytest
from app.utils.password_validator import validate_password # 아직 없는 파일
def test_비밀번호_8자_이상():
assert validate_password("short") is False
assert validate_password("longpassword") is True
def test_비밀번호_대문자_포함():
assert validate_password("alllowercase1!") is False
assert validate_password("HasUpper1!") is True
def test_비밀번호_숫자_포함():
assert validate_password("NoNumbers!") is False
assert validate_password("HasNumber1!") is True
def test_비밀번호_특수문자_포함():
assert validate_password("NoSpecial1") is False
assert validate_password("HasSpecial1!") is True
지금 이 테스트를 실행하면 ImportError로 실패한다. 아직 validate_password가 없으니 당연하다.
pytest tests/unit/test_password_validator.py
# ERROR: cannot import name 'validate_password'
Step 2 - Green: 테스트를 통과시키는 코드 작성
# app/utils/password_validator.py
import re
def validate_password(password: str) -> bool:
if len(password) < 8:
return False
if not re.search(r'[A-Z]', password):
return False
if not re.search(r'[0-9]', password):
return False
if not re.search(r'[!@#$%^&*]', password):
return False
return True
pytest tests/unit/test_password_validator.py
# 4 passed ✓
Step 3 - Refactor: 코드 정리
# app/utils/password_validator.py
import re
RULES = [
(r'.{8,}', "8자 이상"),
(r'[A-Z]', "대문자 포함"),
(r'[0-9]', "숫자 포함"),
(r'[!@#$%^&*]', "특수문자 포함"),
]
def validate_password(password: str) -> bool:
return all(re.search(pattern, password) for pattern, _ in RULES)
pytest tests/unit/test_password_validator.py
# 4 passed ✓ — 리팩터링 후에도 테스트 통과
리팩터링 후에도 테스트가 통과하면 동작이 유지된다는 것이 보장된다.
TDD로 API 엔드포인트 개발하기
실제 개발 흐름을 TDD로 따라가본다.
요구사항: 유저 검색 API를 추가한다 (GET /users?name=홍)
# Step 1: 테스트 먼저 작성
# tests/integration/test_user_search.py
def test_이름으로_유저_검색(client):
# Given
client.post("/users", json={"name": "홍길동", "email": "hong@test.com"})
client.post("/users", json={"name": "홍판서", "email": "hong2@test.com"})
client.post("/users", json={"name": "이몽룡", "email": "lee@test.com"})
# When
response = client.get("/users?name=홍")
# Then
assert response.status_code == 200
results = response.json()
assert len(results) == 2
assert all("홍" in u["name"] for u in results)
def test_검색_결과_없음(client):
response = client.get("/users?name=없는이름")
assert response.status_code == 200
assert response.json() == []
pytest tests/integration/test_user_search.py
# FAILED — /users?name=홍 이 아직 구현 안 됨
# Step 2: 구현
# app/routers/user_router.py
from typing import Optional
@router.get("/users")
def get_users(name: Optional[str] = None, service: UserService = Depends(get_user_service)):
return service.get_users(name_filter=name)
# app/services/user_service.py
def get_users(self, name_filter: Optional[str] = None) -> list[UserResponse]:
return self.repo.find_all(name_filter=name_filter)
pytest tests/integration/test_user_search.py
# 2 passed ✓
테스트 실행
# 전체 테스트 실행
pytest
# 특정 디렉토리만
pytest tests/unit/
pytest tests/integration/
# 특정 파일만
pytest tests/unit/test_user_service.py
# 특정 테스트 함수만
pytest tests/unit/test_user_service.py::test_create_user_성공
# 상세 출력
pytest -v
# 실패 시 즉시 중단
pytest -x
# 커버리지 측정
pip install pytest-cov
pytest --cov=app --cov-report=term-missing
테스트 커버리지
pytest --cov=app --cov-report=term-missing
# 출력 예시
Name Stmts Miss Cover
-------------------------------------------------
app/main.py 8 0 100%
app/routers/user_router.py 25 2 92%
app/services/user_service.py 18 1 94%
app/repositories/user_repo.py 20 0 100%
-------------------------------------------------
TOTAL 71 3 96%
커버리지 100%가 목표가 아니다. 핵심 비즈니스 로직과 경계 조건에 집중하는 것이 더 중요하다.
전략 정리
| 상황 | 추천 방식 |
|---|---|
| 비즈니스 로직 검증 | 단위 테스트 + Mock |
| API 동작 검증 | 통합 테스트 + 테스트 DB |
| 사용자 시나리오 검증 | E2E 테스트 |
| 새 기능 개발 | TDD (테스트 먼저) |
| 버그 수정 | 버그를 재현하는 테스트 먼저 작성 후 수정 |
테스트를 처음 도입할 때는 통합 테스트부터 시작하는 것이 효과적이다. 코드 변경 없이 기존 동작을 검증하는 안전망을 먼저 구축한 후, 단위 테스트와 TDD를 점진적으로 적용한다.
시리즈: Python FastAPI 입문
- 시작하기 전에
- FastAPI 구조 - 라우터, 3계층 아키텍처
- Pydantic과 의존성 주입
- REST API 만들기
- SQLAlchemy와 데이터베이스 연동
- 테스트 - 단위 테스트, 통합 테스트, E2E, TDD ← 현재 글