자동화된 테스트
자동화된 테스트는 현대 소프트웨어 개발에서 필수적인 요소로, 코드의 품질과 안정성을 보장하는 핵심 도구입니다. FastAPI는 테스트를 위한 강력한 도구들을 기본 제공하여 효율적인 테스트 환경을 구축할 수 있습니다.
자동화된 테스트 장점
자동화된 테스트는 개발 과정에서 다음과 같은 중요한 이점들을 제공합니다:
- 일관성과 반복성 : 매번 동일한 조건에서 테스트를 수행하여 일관된 결과를 보장하고, 사람의 실수를 최소화합니다.
- 효율성과 시간 절약 : 수동 테스트 대비 대폭적인 시간 절약이 가능하며, 특히 회귀 테스트에서 그 효과가 극대화됩니다.
- 신뢰성과 안정성 : 코드 변경 시 기존 기능의 정상 동작 여부를 즉시 확인할 수 있어 안전한 리팩토링과 기능 추가가 가능합니다.
- 살아있는 문서화 : 테스트 코드 자체가 API의 사용법과 예상 동작을 명확하게 문서화하는 역할을 수행합니다.
- 품질 향상과 조기 버그 발견 : 개발 초기 단계에서 버그를 발견하고 수정할 수 있어 전체적인 코드 품질이 향상됩니다.
라이브러리 설치
FastAPI에서는 pytest, httpx 라이브러리를 활용한 테스트를 기본 지원합니다. (공식문서)
기본 테스트 수행
앞서 구현한 entry.py 파일과 같은 경로에 아래의 test_entry.py 파일을 생성합니다.
entry.py 에서는 /posts/ 주소에 대해 POST 요청 만을 지원하고 있습니다.
/ 주소에 대해서는 404 응답을 받아야만 합니다.
test_* 함수 내에서 assert 문을 통해 다양한 상황에 대한 검증을 수행합니다.
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 ====================
포스팅 목록 엔드포인트 및 테스트 작성
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"
}
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"
생성 요청에 대한 테스트
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-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를 설치하고 활성화합니다:
방법 2: 수동 Git Hook 설정
.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."
실행 권한을 부여합니다:
동작 확인
이제 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 등)
- 설정 파일로 관리 가능
- 팀 전체가 동일한 설정 공유
- 커밋 속도 최적화 (변경된 파일만 검사)