Python FastAPI 입문(6/6)
Python/FastAPI

FastAPI 테스트 - 단위 테스트, 통합 테스트, E2E 테스트, TDD

pytest로 FastAPI 애플리케이션의 단위 테스트, 통합 테스트, E2E 테스트를 작성하는 방법과 TDD 개발 방식을 이해한다.

2026-04-05
18 min read
#Python#FastAPI#pytest#테스트#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 입문

  1. 시작하기 전에
  2. FastAPI 구조 - 라우터, 3계층 아키텍처
  3. Pydantic과 의존성 주입
  4. REST API 만들기
  5. SQLAlchemy와 데이터베이스 연동
  6. 테스트 - 단위 테스트, 통합 테스트, E2E, TDD ← 현재 글