Skip to content

자동화된 테스트

자동화된 테스트는 현대 소프트웨어 개발에서 필수적인 요소로, 코드의 품질과 안정성을 보장하는 핵심 도구입니다. FastAPI는 테스트를 위한 강력한 도구들을 기본 제공하여 효율적인 테스트 환경을 구축할 수 있습니다.

자동화된 테스트 장점

자동화된 테스트는 개발 과정에서 다음과 같은 중요한 이점들을 제공합니다:

  1. 일관성과 반복성 : 매번 동일한 조건에서 테스트를 수행하여 일관된 결과를 보장하고, 사람의 실수를 최소화합니다.
  2. 효율성과 시간 절약 : 수동 테스트 대비 대폭적인 시간 절약이 가능하며, 특히 회귀 테스트에서 그 효과가 극대화됩니다.
  3. 신뢰성과 안정성 : 코드 변경 시 기존 기능의 정상 동작 여부를 즉시 확인할 수 있어 안전한 리팩토링과 기능 추가가 가능합니다.
  4. 살아있는 문서화 : 테스트 코드 자체가 API의 사용법과 예상 동작을 명확하게 문서화하는 역할을 수행합니다.
  5. 품질 향상과 조기 버그 발견 : 개발 초기 단계에서 버그를 발견하고 수정할 수 있어 전체적인 코드 품질이 향상됩니다.

라이브러리 설치

FastAPI에서는 pytest, httpx 라이브러리를 활용한 테스트를 기본 지원합니다. (공식문서)

pip install -U pytest httpx

기본 테스트 수행

앞서 구현한 entry.py 파일과 같은 경로에 아래의 test_entry.py 파일을 생성합니다.

entry.py 에서는 /posts/ 주소에 대해 POST 요청 만을 지원하고 있습니다. / 주소에 대해서는 404 응답을 받아야만 합니다. test_* 함수 내에서 assert 문을 통해 다양한 상황에 대한 검증을 수행합니다.

import pytest
from fastapi.testclient import TestClient
from entry import app

client = TestClient(app)

def test_post_list():
    response = client.get("/")
    assert response.status_code == 404
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class PostCreated(BaseModel):
    title: str
    content: str = ""

class PostResponse(BaseModel):
    id: int
    title: str
    content: str

@app.post("/posts/", response_model=PostResponse)
async def post_new(post: PostCreated):
    # TODO: 데이터베이스에 저장
    return {
        "id": 123,
        "title": post.title,
        "content": post.content,
    }

명령행에서 pytest 명령으로 테스트를 pytest 디폴트 설정으로 test_*로 시작하는 파일명에서 test_*로 시작하는 함수명으로 시작하는 테스트를 자동으로 탐지하여 자동 수행합니다.

$ pytest
=================== test session starts ===================
platform darwin -- Python 3.13.2, pytest-8.3.5, pluggy-1.5.0
rootdir: /Users/allieus/Temp/test-fastapi
plugins: testdox-3.1.0, httpx-0.35.0, anyio-4.8.0,
         django-4.11.1, asyncio-0.26.0, langsmith-0.3.21, cov-6.1.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None,
         asyncio_default_test_loop_scope=function
collected 1 item

test_entry.py .                                     [100%]

==================== 1 passed in 0.19s ====================

포스팅 목록 엔드포인트 및 테스트 작성

entry.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class PostCreated(BaseModel):
    title: str
    content: str = ""

class PostResponse(BaseModel):
    id: int
    title: str
    content: str

@app.get("/posts/", response_model=list[PostResponse])
async def post_list():
    # TODO: 데이터베이스로부터 조회
    return [
        {"id": 1, "title": "제목 #1", "content": "내용 #1"},
        {"id": 2, "title": "제목 #2", "content": "내용 #2"},
        {"id": 3, "title": "제목 #3", "content": "내용 #3"},
    ]

@app.post("/posts/", response_model=PostResponse)
async def post_new(post: PostCreated):
    # TODO: 데이터베이스에 저장
    return {
        "id": 123,
        "title": "new title",
        "content": "new content"
    }
test_entry.py
import pytest
from fastapi.testclient import TestClient
from entry import app

client = TestClient(app)

def test_post_list():
    response = client.get("/posts/")
    assert response.status_code == 200
    assert response.headers["content-type"] == "application/json"

생성 요청에 대한 테스트

test_entry.py
def test_post_new():
    # 새 게시글 생성 테스트
    new_post_data = {
        "title": "테스트 제목",
        "content": "테스트 내용"
    }

    response = client.post("/posts/", json=new_post_data)
    assert response.status_code == 200
    assert response.headers["content-type"] == "application/json"

    response_obj = response.json()

    # 응답 데이터 검증
    assert "id" in response_obj
    assert "title" in response_obj
    assert "content" in response_obj
    assert isinstance(response_obj["id"], int)


def test_post_new_missing_title():
    """필수 필드 title이 누락된 경우 422 에러 테스트"""
    new_post_data = {
        "content": "제목이 없는 내용"
    }

    response = client.post("/posts/", json=new_post_data)
    assert response.status_code == 422  # Validation Error

    error_detail = response.json()
    assert "detail" in error_detail
    # title 필드 누락 에러 확인
    assert any(
        error["loc"] == ["body", "title"]
        for error in error_detail["detail"]
    )


def test_post_new_missing_content():
    """선택적 필드 content가 누락된 경우 정상 처리 테스트"""
    new_post_data = {
        "title": "제목만 있는 게시글"
    }

    response = client.post("/posts/", json=new_post_data)
    assert response.status_code == 200  # content는 기본값이 있으므로 정상 처리

    response_obj = response.json()
    assert response_obj["title"] == "제목만 있는 게시글"
    # content는 기본값 "" 또는 서버에서 설정한 값


def test_post_new_missing_all_fields():
    """모든 필드가 누락된 경우 422 에러 테스트"""
    new_post_data = {}

    response = client.post("/posts/", json=new_post_data)
    assert response.status_code == 422  # Validation Error

    error_detail = response.json()
    assert "detail" in error_detail
    # title 필드 누락 에러 확인
    assert any(error["loc"] == ["body", "title"] for error in error_detail["detail"])


def test_post_new_invalid_title_type():
    """title 필드가 문자열이 아닌 경우 422 에러 테스트"""
    new_post_data = {
        "title": 123,  # 숫자 타입
        "content": "정상적인 내용"
    }

    response = client.post("/posts/", json=new_post_data)
    assert response.status_code == 422  # Validation Error

    error_detail = response.json()
    assert "detail" in error_detail
    # title 필드 타입 에러 확인
    assert any(error["loc"] == ["body", "title"] for error in error_detail["detail"])


def test_post_new_invalid_content_type():
    """content 필드가 문자열이 아닌 경우 422 에러 테스트"""
    new_post_data = {
        "title": "정상적인 제목",
        "content": ["리스트", "타입"]  # 리스트 타입
    }

    response = client.post("/posts/", json=new_post_data)
    assert response.status_code == 422  # Validation Error

    error_detail = response.json()
    assert "detail" in error_detail
    # content 필드 타입 에러 확인
    assert any(error["loc"] == ["body", "content"] for error in error_detail["detail"])


def test_post_new_null_values():
    """null 값이 전달된 경우 422 에러 테스트"""
    new_post_data = {
        "title": None,  # null 값
        "content": "정상적인 내용"
    }

    response = client.post("/posts/", json=new_post_data)
    assert response.status_code == 422  # Validation Error

    error_detail = response.json()
    assert "detail" in error_detail
    # title 필드 null 에러 확인
    assert any(error["loc"] == ["body", "title"] for error in error_detail["detail"])


def test_post_new_empty_string_title():
    """빈 문자열 title에 대한 처리 테스트"""
    new_post_data = {
        "title": "",  # 빈 문자열
        "content": "정상적인 내용"
    }

    response = client.post("/posts/", json=new_post_data)
    # 빈 문자열도 유효한 문자열이므로 200 또는 비즈니스 로직에 따라 422
    # 현재 Pydantic 모델에서는 빈 문자열을 허용하므로 200 예상
    assert response.status_code == 200

    response_obj = response.json()
    assert response_obj["title"] == ""
$ pytest
=================== test session starts ===================
platform darwin -- Python 3.13.2, pytest-8.3.5, pluggy-1.5.0
rootdir: /Users/allieus/Temp/test-fastapi
plugins: testdox-3.1.0, httpx-0.35.0, anyio-4.8.0,
         django-4.11.1, asyncio-0.26.0, langsmith-0.3.21, cov-6.1.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None,
         asyncio_default_test_loop_scope=function
collected 9 items

test_entry.py .........                              [100%]

==================== 9 passed in 0.22s ====================

Git Commit 전 자동 테스트 실행

Git commit 전에 자동으로 테스트를 실행하여 테스트가 실패하면 커밋을 중단하도록 설정할 수 있습니다.

방법 1: pre-commit 라이브러리 사용 (권장)

# pre-commit 라이브러리 설치
pip install pre-commit

프로젝트 루트에 .pre-commit-config.yaml 파일을 생성합니다:

.pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: pytest
        name: pytest
        entry: pytest
        language: system
        pass_filenames: false
        always_run: true
        stages: [commit]
        args: [--tb=short]

  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black
        language_version: python3

Git hooks를 설치하고 활성화합니다:

# Git hooks 설치
pre-commit install

# 모든 파일에 대해 한 번 실행 (선택사항)
pre-commit run --all-files

방법 2: 수동 Git Hook 설정

.git/hooks/pre-commit 파일을 생성합니다:

.git/hooks/pre-commit
#!/bin/sh
# Git pre-commit hook

echo "Running tests before commit..."

# pytest 실행
pytest

# 테스트 실패 시 커밋 중단
if [ $? -ne 0 ]; then
    echo "Tests failed. Commit aborted."
    exit 1
fi

echo "All tests passed. Proceeding with commit."

실행 권한을 부여합니다:

chmod +x .git/hooks/pre-commit

동작 확인

이제 git commit 실행 시 자동으로 테스트가 실행됩니다:

$ git commit -m "Add new feature"
Running tests before commit...
=================== test session starts ===================
...
==================== 9 passed in 0.22s ====================
All tests passed. Proceeding with commit.
[main abc1234] Add new feature
 2 files changed, 15 insertions(+), 3 deletions(-)

테스트가 실패하면 커밋이 중단됩니다:

$ git commit -m "Broken feature"
Running tests before commit...
=================== FAILURES ===================
...
Tests failed. Commit aborted.

pre-commit 라이브러리 장점

  • 다양한 도구 통합 (black, flake8, mypy 등)
  • 설정 파일로 관리 가능
  • 팀 전체가 동일한 설정 공유
  • 커밋 속도 최적화 (변경된 파일만 검사)

Comments