pytest로 테스트 작성하기 — 픽스처부터 Mock까지

pytest란?

pytest는 파이썬에서 가장 널리 사용되는 테스트 프레임워크입니다. 내장 unittest보다 간결한 문법, 강력한 픽스처 시스템, 풍부한 플러그인 생태계를 제공합니다. 별도의 클래스 없이 함수만으로 테스트를 작성할 수 있어 진입 장벽이 낮습니다.

이 글에서는 기본 사용법부터 픽스처, 파라미터화, mock, 그리고 실전 팁까지 다룹니다.

설치와 기본 사용법

pip install pytest

pytest는 test_로 시작하는 파일과 함수를 자동으로 찾아 실행합니다. assert 문으로 검증하면 됩니다.

# test_calculator.py
def add(a: int, b: int) -> int:
    """두 수를 더합니다."""
    return a + b

def subtract(a: int, b: int) -> int:
    """두 수를 뺍니다."""
    return a - b

# 테스트 함수는 test_로 시작
def test_add():
    assert add(2, 3) == 5          # 기본 덧셈
    assert add(-1, 1) == 0         # 음수 덧셈
    assert add(0, 0) == 0          # 제로 케이스

def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(1, 5) == -4    # 음수 결과

def test_add_type_error():
    """잘못된 타입 입력 시 예외 발생 테스트"""
    import pytest
    with pytest.raises(TypeError):
        add("문자열", 3)  # TypeError 발생
# 실행
pytest test_calculator.py -v

# 출력 예시:
# test_calculator.py::test_add PASSED
# test_calculator.py::test_subtract PASSED
# test_calculator.py::test_add_type_error PASSED
# ============ 3 passed in 0.02s ============
pytest 명령 옵션설명
-v상세 출력 (각 테스트 이름 표시)
-x첫 실패 시 중단
-k "keyword"키워드로 테스트 필터링
--tb=short짧은 트레이스백
-sprint 출력 표시

픽스처 (Fixture)

픽스처는 테스트에 필요한 사전 준비와 정리 작업을 재사용 가능한 함수로 분리합니다. @pytest.fixture 데코레이터로 정의하고, 테스트 함수의 매개변수로 주입받습니다.

# test_user_service.py
import pytest

class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

class UserService:
    def __init__(self):
        self.users: dict[str, User] = {}

    def add_user(self, user: User) -> None:
        if user.email in self.users:
            raise ValueError("이미 존재하는 이메일입니다")
        self.users[user.email] = user

    def get_user(self, email: str) -> User:
        if email not in self.users:
            raise KeyError("사용자를 찾을 수 없습니다")
        return self.users[email]

# 픽스처: 테스트마다 새로운 서비스 인스턴스 생성
@pytest.fixture
def user_service():
    """깨끗한 UserService 인스턴스를 제공합니다."""
    service = UserService()
    # 기본 테스트 데이터 추가
    service.add_user(User("홍길동", "hong@example.com"))
    return service

# 픽스처를 매개변수로 주입받아 사용
def test_get_existing_user(user_service):
    user = user_service.get_user("hong@example.com")
    assert user.name == "홍길동"

def test_add_duplicate_user(user_service):
    duplicate = User("다른이름", "hong@example.com")
    with pytest.raises(ValueError, match="이미 존재하는 이메일"):
        user_service.add_user(duplicate)

def test_get_nonexistent_user(user_service):
    with pytest.raises(KeyError, match="사용자를 찾을 수 없습니다"):
        user_service.get_user("nobody@example.com")

각 테스트 함수가 실행될 때마다 user_service 픽스처가 새로 생성되므로 테스트 간 상태 오염이 발생하지 않습니다.

픽스처 스코프와 yield

픽스처의 scope 매개변수로 생명주기를 제어할 수 있습니다. yield를 사용하면 정리(teardown) 로직도 정의할 수 있습니다.

import pytest
import tempfile
import os

@pytest.fixture(scope="function")  # 기본값: 테스트 함수마다 실행
def temp_file():
    """임시 파일을 생성하고, 테스트 후 삭제합니다."""
    fd, path = tempfile.mkstemp(suffix=".txt")
    os.close(fd)
    print(f"\n[SETUP] 임시 파일 생성: {path}")

    yield path  # 이 값이 테스트에 전달됨

    # yield 이후는 teardown (테스트 완료 후 실행)
    if os.path.exists(path):
        os.remove(path)
        print(f"[TEARDOWN] 임시 파일 삭제: {path}")

def test_write_to_temp_file(temp_file):
    with open(temp_file, "w") as f:
        f.write("테스트 데이터")

    with open(temp_file, "r") as f:
        assert f.read() == "테스트 데이터"
스코프실행 시점
function각 테스트 함수마다 (기본값)
class각 테스트 클래스마다
module각 모듈(파일)마다
session전체 테스트 세션에서 한 번

파라미터화 (Parametrize)

동일한 테스트 로직을 다양한 입력값으로 반복 실행하려면 @pytest.mark.parametrize를 사용합니다.

import pytest

def is_palindrome(text: str) -> bool:
    """회문(앞뒤가 같은 문자열) 여부를 판별합니다."""
    cleaned = text.lower().replace(" ", "")
    return cleaned == cleaned[::-1]

@pytest.mark.parametrize("text, expected", [
    ("racecar", True),        # 영어 회문
    ("hello", False),         # 비회문
    ("A man a plan a canal Panama", True),  # 공백 포함 회문
    ("토마토", True),          # 한글 회문
    ("파이썬", False),         # 한글 비회문
    ("", True),               # 빈 문자열
])
def test_is_palindrome(text, expected):
    assert is_palindrome(text) == expected

# 실행 결과:
# test_palindrome.py::test_is_palindrome[racecar-True] PASSED
# test_palindrome.py::test_is_palindrome[hello-False] PASSED
# test_palindrome.py::test_is_palindrome[A man a plan...-True] PASSED
# test_palindrome.py::test_is_palindrome[토마토-True] PASSED
# test_palindrome.py::test_is_palindrome[파이썬-False] PASSED
# test_palindrome.py::test_is_palindrome[-True] PASSED
# ============ 6 passed in 0.01s ============

파라미터화를 사용하면 테스트 케이스를 쉽게 추가할 수 있고, 각 케이스가 독립적으로 실행되어 어떤 입력에서 실패했는지 즉시 확인할 수 있습니다.

Mock — 외부 의존성 격리

unittest.mock 또는 pytest-mock을 사용하면 외부 API, DB 등의 의존성을 가짜 객체로 대체할 수 있습니다.

# weather_service.py
import requests

def get_temperature(city: str) -> float:
    """외부 날씨 API에서 온도를 가져옵니다."""
    response = requests.get(
        f"https://api.weather.example.com/{city}"
    )
    response.raise_for_status()
    data = response.json()
    return data["temperature"]

# test_weather_service.py
from unittest.mock import patch, MagicMock

def test_get_temperature_success():
    """정상 응답 시 온도를 올바르게 반환하는지 테스트"""
    # requests.get을 가짜 객체로 대체
    mock_response = MagicMock()
    mock_response.json.return_value = {"temperature": 22.5}
    mock_response.raise_for_status.return_value = None

    with patch("weather_service.requests.get",
               return_value=mock_response) as mock_get:
        temp = get_temperature("Seoul")

        assert temp == 22.5
        # API가 올바른 URL로 호출되었는지 검증
        mock_get.assert_called_once_with(
            "https://api.weather.example.com/Seoul"
        )

def test_get_temperature_api_error():
    """API 오류 시 예외가 발생하는지 테스트"""
    mock_response = MagicMock()
    mock_response.raise_for_status.side_effect = (
        requests.exceptions.HTTPError("500 Server Error")
    )

    with patch("weather_service.requests.get",
               return_value=mock_response):
        with pytest.raises(requests.exceptions.HTTPError):
            get_temperature("Seoul")

Mock을 사용하면 네트워크 없이도 테스트를 실행할 수 있고, 다양한 응답 시나리오(성공, 실패, 타임아웃)를 시뮬레이션할 수 있습니다.

conftest.py — 공유 픽스처

여러 테스트 파일에서 공통으로 사용하는 픽스처는 conftest.py에 정의합니다. pytest가 자동으로 인식합니다.

# conftest.py (tests/ 디렉토리에 위치)
import pytest

@pytest.fixture
def sample_users():
    """여러 테스트에서 공유하는 사용자 데이터"""
    return [
        {"name": "홍길동", "email": "hong@test.com", "age": 30},
        {"name": "김영희", "email": "kim@test.com", "age": 25},
        {"name": "이철수", "email": "lee@test.com", "age": 35},
    ]

@pytest.fixture(autouse=True)
def reset_environment(monkeypatch):
    """모든 테스트에서 환경변수를 초기화합니다."""
    monkeypatch.setenv("APP_ENV", "test")
    monkeypatch.setenv("DEBUG", "true")

autouse=True로 설정하면 해당 디렉토리의 모든 테스트에 자동 적용됩니다.

실전 팁

  • 테스트 네이밍: test_대상_시나리오_기대결과 형식으로 작성하면 실패 시 원인을 빠르게 파악할 수 있습니다
  • AAA 패턴: Arrange(준비) → Act(실행) → Assert(검증) 3단계로 구조화합니다
  • 한 테스트 하나의 검증: 하나의 테스트 함수에서 하나의 동작만 검증합니다
  • 테스트 격리: 테스트 간 상태를 공유하지 않습니다. 픽스처로 매번 새로운 상태를 만듭니다
  • 경계값 테스트: 빈 값, None, 최대값, 음수 등 경계 조건을 반드시 테스트합니다
  • 커버리지 측정: pytest --cov=src --cov-report=html로 커버리지를 확인합니다
  • 느린 테스트 분리: @pytest.mark.slow로 마킹하고 CI에서 별도 실행합니다

이 글이 도움이 되었나요?