콘텐츠로 이동

테스트 — 코드를 믿을 수 있게 만들기

왜 테스트를 작성하는가?

코드를 바꿀 때마다 이런 불안감이 생깁니다.

"이 함수를 고쳤는데, 다른 곳이 망가지진 않았을까?"
"새 기능을 추가했는데, 기존 기능이 여전히 잘 되는지 확인해야 해."
"배포하기 전에 전부 손으로 테스트하려면 시간이 너무 오래 걸려."

자동화된 테스트는 이 문제를 해결합니다.
코드를 바꿀 때마다 테스트를 실행해서 기존 기능이 망가지지 않았음을 확인합니다.

테스트 없이:  코드 수정 → 수동으로 전부 확인 → 배포
테스트 있이:  코드 수정 → 테스트 실행 (자동) → 문제 없으면 배포

테스트의 종류

단위 테스트 (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
커버리지 테스트가 실행한 코드의 비율

테스트는 "잘 동작하는지" 확인하는 것만큼
"잘못된 입력을 제대로 거부하는지"도 확인해야 합니다.