테스트 — 코드를 믿을 수 있게 만들기¶
왜 테스트를 작성하는가?¶
코드를 바꿀 때마다 이런 불안감이 생깁니다.
"이 함수를 고쳤는데, 다른 곳이 망가지진 않았을까?"
"새 기능을 추가했는데, 기존 기능이 여전히 잘 되는지 확인해야 해."
"배포하기 전에 전부 손으로 테스트하려면 시간이 너무 오래 걸려."
자동화된 테스트는 이 문제를 해결합니다.
코드를 바꿀 때마다 테스트를 실행해서 기존 기능이 망가지지 않았음을 확인합니다.
테스트 없이: 코드 수정 → 수동으로 전부 확인 → 배포
테스트 있이: 코드 수정 → 테스트 실행 (자동) → 문제 없으면 배포
테스트의 종류¶
단위 테스트 (Unit Test):
함수 하나를 독립적으로 테스트
"hash_password()가 항상 다른 값을 반환하는가?"
통합 테스트 (Integration Test):
여러 컴포넌트가 함께 동작하는지 테스트
"POST /todos를 호출하면 DB에 실제로 저장되는가?"
E2E 테스트 (End-to-End):
실제 사용자 시나리오 전체를 테스트
"로그인 → 할 일 추가 → 완료 체크 → 삭제"
이 장에서는 단위 테스트와 통합 테스트를 다룹니다.
pytest — Python 테스트 도구¶
pip install pytest httpx
테스트 파일은 test_로 시작하거나 _test.py로 끝납니다.
# test_auth.py
def test_add():
assert 1 + 1 == 2
def test_string():
assert "hello".upper() == "HELLO"
pytest # 현재 디렉토리의 모든 테스트 실행
pytest test_auth.py # 특정 파일만 실행
pytest -v # 상세 출력
단위 테스트¶
auth.py 테스트¶
# test_auth.py
from auth import hash_password, verify_password, create_access_token, decode_token
def test_hash_password_different_each_time():
# 같은 비밀번호도 매번 다른 해시를 생성해야 함 (솔트)
h1 = hash_password("mypassword")
h2 = hash_password("mypassword")
assert h1 != h2
def test_verify_password_correct():
hashed = hash_password("mypassword")
assert verify_password("mypassword", hashed) is True
def test_verify_password_wrong():
hashed = hash_password("mypassword")
assert verify_password("wrongpassword", hashed) is False
def test_token_roundtrip():
# 토큰을 만들고 다시 디코딩하면 원래 user_id가 나와야 함
token = create_access_token(user_id=42)
assert decode_token(token) == 42
pytest test_auth.py -v
# PASSED test_hash_password_different_each_time
# PASSED test_verify_password_correct
# PASSED test_verify_password_wrong
# PASSED test_token_roundtrip
경계값 테스트¶
# test_validation.py
from pydantic import ValidationError
from main import TodoCreate
def test_todo_title_required():
try:
TodoCreate(title="") # 빈 문자열 — 실패해야 함
assert False, "예외가 발생해야 함"
except ValidationError:
pass # 예상된 동작
def test_todo_title_valid():
todo = TodoCreate(title="운동하기")
assert todo.title == "운동하기"
def test_todo_title_too_long():
try:
TodoCreate(title="a" * 201) # 200자 초과
assert False
except ValidationError:
pass
API 통합 테스트 — TestClient¶
FastAPI는 HTTP 서버를 실제로 실행하지 않고 테스트할 수 있는 TestClient를 제공합니다.
# test_api.py
import pytest
from fastapi.testclient import TestClient
from main import app
from database import init_db, get_db
# 테스트용 클라이언트
client = TestClient(app)
def test_create_todo():
response = client.post("/todos", json={"title": "테스트 할 일"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "테스트 할 일"
assert data["is_done"] == False
assert "id" in data
def test_get_todos():
response = client.get("/todos")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_get_todo_not_found():
response = client.get("/todos/99999")
assert response.status_code == 404
def test_delete_todo():
# 먼저 생성
create_res = client.post("/todos", json={"title": "삭제될 항목"})
todo_id = create_res.json()["id"]
# 삭제
delete_res = client.delete(f"/todos/{todo_id}")
assert delete_res.status_code == 200
# 다시 조회하면 404
get_res = client.get(f"/todos/{todo_id}")
assert get_res.status_code == 404
테스트 격리 — 테스트 DB 분리¶
테스트가 실제 데이터베이스를 오염시키면 안 됩니다.
테스트 전용 DB를 사용하고 매 테스트 후 초기화합니다.
# conftest.py — pytest 설정 파일
import pytest
from fastapi.testclient import TestClient
import sqlite3
import os
# 테스트용 DB 파일
TEST_DB = "test.db"
@pytest.fixture(autouse=True)
def setup_test_db():
# 테스트 시작 전: 테스트 DB 초기화
if os.path.exists(TEST_DB):
os.remove(TEST_DB)
conn = sqlite3.connect(TEST_DB)
conn.execute("""
CREATE TABLE todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
is_done INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
""")
conn.commit()
conn.close()
yield # 테스트 실행
# 테스트 끝나면 DB 삭제
if os.path.exists(TEST_DB):
os.remove(TEST_DB)
AAA 패턴 — 테스트 구조¶
좋은 테스트는 세 부분으로 나뉩니다.
def test_create_todo():
# Arrange — 준비
title = "운동하기"
# Act — 실행
response = client.post("/todos", json={"title": title})
# Assert — 검증
assert response.status_code == 201
assert response.json()["title"] == title
무엇을 테스트해야 하는가?¶
테스트해야 하는 것:
- 정상 동작 (Happy Path): 올바른 입력에 올바른 응답
- 오류 동작 (Error Path): 잘못된 입력, 없는 데이터
- 경계값: 빈 문자열, 최대 길이, 0, 음수 등
테스트하지 않아도 되는 것:
- FastAPI나 Python 자체의 기능
- 외부 라이브러리 내부 동작
테스트 커버리지¶
얼마나 많은 코드가 테스트되었는지 측정합니다.
pip install pytest-cov
pytest --cov=. --cov-report=term-missing
Name Stmts Miss Cover
---------------------------------
auth.py 24 0 100%
database.py 8 0 100%
main.py 45 5 89%
100% 커버리지가 목표는 아닙니다.
중요한 로직과 오류 경로가 테스트되는 것이 더 중요합니다.
실습 미션¶
미션 1: 단위 테스트 작성¶
auth.py의 함수들을 테스트하는 test_auth.py를 작성하세요:
1. 비밀번호 해싱 테스트
2. 비밀번호 검증 테스트 (맞는 경우, 틀린 경우)
3. JWT 토큰 생성 및 디코딩 테스트
pytest로 실행해 모두 통과시키세요.
미션 2: API 테스트 작성¶
TestClient를 사용해 다음을 테스트하세요:
1. POST /todos — 성공 케이스
2. POST /todos — 빈 제목 (400 예상)
3. GET /todos/{id} — 존재하는 ID
4. GET /todos/{id} — 없는 ID (404 예상)
5. DELETE /todos/{id} — 성공 케이스
미션 3: 인증 API 테스트¶
1. POST /auth/signup — 정상 회원가입
2. POST /auth/signup — 중복 이메일 (400 예상)
3. POST /auth/login — 올바른 비밀번호
4. POST /auth/login — 틀린 비밀번호 (401 예상)
미션 4 (심화): 인증 필요 엔드포인트 테스트¶
1. GET /todos — 토큰 없이 호출 (401 예상)
2. GET /todos — 유효한 토큰으로 호출 (200 예상)
3. DELETE /todos/{id} — 다른 사람 항목 삭제 (403 예상)
핵심 요약¶
| 개념 | 설명 |
|---|---|
| 단위 테스트 | 함수 하나를 독립적으로 검증 |
| 통합 테스트 | 여러 컴포넌트가 함께 작동하는지 검증 |
| TestClient | FastAPI 서버 없이 HTTP 요청 시뮬레이션 |
assert |
예상 결과와 실제 결과 비교 |
| fixture | 테스트 전후 공통 설정/정리 |
| AAA 패턴 | Arrange → Act → Assert |
| 커버리지 | 테스트가 실행한 코드의 비율 |
테스트는 "잘 동작하는지" 확인하는 것만큼
"잘못된 입력을 제대로 거부하는지"도 확인해야 합니다.