ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Python] pytest, pytest-mock 라이브러리
    언어/파이썬 & 장고 2026. 4. 12. 22:01

    1. 개요

    pytestpytest-mock은 Python 생태계에서 가장 널리 사용되는 테스트 관련 라이브러리입니다. 두 라이브러리는 함께 사용하도록 설계되어 있으며, 단위 테스트(Unit Test)부터 통합 테스트(Integration Test)까지 다양한 테스트 시나리오를 깔끔하게 작성할 수 있도록 도와줍니다.

    구분 pytest pytest-mock
    역할 테스트 프레임워크 (실행, 발견, 보고) 모킹(Mocking) 지원 플러그인
    핵심 개념 Fixture, Marker, Parametrize mocker fixture, patch, spy
    독립 사용 가능 pytest 의존 필수

    2. 라이브러리 기능 설명

    2-1. pytest

    pytest는 Python의 표준 unittest보다 훨씬 간결하고 강력한 테스트 프레임워크입니다. 보일러플레이트 코드 없이 일반 함수로 테스트를 작성할 수 있으며, 풍부한 플러그인 생태계를 보유하고 있습니다.

    핵심 기능

    • Fixture — 테스트 의존성 및 환경 설정 관리
        import pytest
      
        @pytest.fixture
        def user():
            return {"id": 1, "name": "홍길동", "email": "hong@example.com"}
      
        def test_user_name(user):
            assert user["name"] == "홍길동"
      Fixture는 scope 설정으로 생명주기를 제어할 수 있습니다.
    • @pytest.fixture(scope="module") # function, class, module, session def db_connection(): conn = create_connection() yield conn # yield 이후는 teardown 로직 conn.close()
    • Fixture는 테스트 함수에 주입되는 재사용 가능한 설정 단위입니다. 데이터베이스 연결, 임시 파일, 공통 객체 등을 깔끔하게 관리할 수 있습니다.
    • Parametrize — 여러 입력값으로 동일한 테스트 반복 실행
        import pytest
      
        @pytest.mark.parametrize("input_val, expected", [
            (1, 2),
            (10, 11),
            (-1, 0),
            (0, 1),
        ])
        def test_increment(input_val, expected):
            assert input_val + 1 == expected
    • @pytest.mark.parametrize 데코레이터를 사용하면 하나의 테스트 함수로 다양한 케이스를 검증할 수 있습니다.
    • Markers — 테스트 분류 및 조건부 실행
        import pytest
        import sys
      
        @pytest.mark.skip(reason="아직 구현 중")
        def test_not_ready():
            pass
      
        @pytest.mark.skipif(sys.platform == "win32", reason="Windows 미지원")
        def test_unix_only():
            pass
      
        @pytest.mark.xfail(reason="알려진 버그 #123")
        def test_known_bug():
            assert 1 == 2
      
        # 커스텀 마커 등록 (pytest.ini 또는 pyproject.toml)
        @pytest.mark.slow
        def test_heavy_computation():
            pass
      실행 시 마커로 필터링할 수 있습니다.
    • pytest -m slow # slow 마커만 실행 pytest -m "not slow" # slow 제외 실행
    • 마커를 통해 테스트를 그룹화하거나 특정 조건에서만 실행할 수 있습니다.
    • Assert 인트로스펙션 — 실패 시 상세한 차이 출력
        def test_list_contains():
            result = [1, 2, 3, 4]
            expected = [1, 2, 3, 5]
            assert result == expected  # 어떤 원소가 다른지 명확히 출력됨
    • assert 문만 사용해도 pytest가 실패 원인을 상세히 출력해 줍니다. assertEqual, assertIn 등 별도 메서드가 필요 없습니다.
    • 예외 검증 — 특정 예외 발생 확인
    • import pytest def divide(a, b): if b == 0: raise ZeroDivisionError("0으로 나눌 수 없습니다.") return a / b def test_divide_by_zero(): with pytest.raises(ZeroDivisionError, match="0으로 나눌 수 없습니다"): divide(10, 0)
    • tmp_path / conftest.py — 내장 Fixture와 전역 설정
      • tmp_path — 테스트별 임시 디렉토리
      • capsys — stdout/stderr 캡처
      • monkeypatch — 환경변수, 속성, 딕셔너리 임시 변경
      • caplog — 로그 캡처
      • conftest.py에 정의한 fixture는 동일 디렉토리 및 하위 디렉토리 전체에서 자동으로 공유됩니다.
    • pytest는 다양한 내장 fixture를 제공합니다.

    2-2. pytest-mock

    pytest-mock은 Python 표준 라이브러리인 unittest.mock을 pytest의 fixture 방식으로 사용할 수 있게 해주는 얇은 래퍼(thin wrapper) 플러그인입니다. mocker fixture 하나로 mock, patch, spy 등 모든 모킹 기능을 간결하게 사용할 수 있습니다.

    핵심 기능

    • mocker.patch — 객체/함수를 가짜(Mock)로 대체반환값 설정:
    • def test_get_user(mocker): mocker.patch("myapp.db.get_user", return_value={"id": 1, "name": "홍길동"}) result = myapp.db.get_user(1) assert result["name"] == "홍길동"
    • import os def test_remove_file(mocker): mocker.patch("os.remove") # os.remove를 Mock으로 대체 os.remove("fake_file.txt") os.remove.assert_called_once_with("fake_file.txt")
    • mocker.patch.object — 특정 객체의 메서드 패치
    • def test_service(mocker): service = MyService() mocker.patch.object(service, "fetch_data", return_value=[1, 2, 3]) result = service.process() assert result == [1, 2, 3]
    • mocker.spy — 실제 동작을 유지하면서 호출 추적
        def test_spy_method(mocker):
            class Calculator:
                def add(self, a, b):
                    return a + b
      
            calc = Calculator()
            spy = mocker.spy(calc, "add")
      
            result = calc.add(3, 4)
      
            assert result == 7                       # 실제 로직 실행됨
            spy.assert_called_once_with(3, 4)        # 호출 검증
            assert spy.spy_return == 7               # 반환값 검증
    • spy는 원본 함수를 그대로 실행하면서 호출 여부, 인자, 반환값을 기록합니다. patch와 달리 실제 로직이 실행됩니다.
    • Mock 클래스 직접 접근 — MagicMock, AsyncMock 등
        def test_mock_classes(mocker):
            # MagicMock 생성
            mock_repo = mocker.MagicMock()
            mock_repo.find.return_value = [{"id": 1}]
      
            # AsyncMock (비동기 함수 모킹)
            mock_async = mocker.AsyncMock(return_value="async result")
      
            # mock_open (파일 읽기 모킹)
            m = mocker.patch("builtins.open", mocker.mock_open(read_data="hello"))
            with open("test.txt") as f:
                assert f.read() == "hello"
    • mocker fixture를 통해 mock 관련 클래스와 헬퍼에 직접 접근할 수 있습니다.
    • 자동 정리(Auto-cleanup) — 테스트 후 자동 원복
    • mocker로 패치한 모든 것은 테스트 종료 후 자동으로 원상복구됩니다. unittest.mock.patch를 데코레이터나 컨텍스트 매니저로 사용할 때와 달리, 명시적으로 stop()을 호출하거나 with 블록을 감쌀 필요가 없습니다.

    3. 설치 방법

    # pytest 설치
    pip install pytest
    
    # pytest-mock 설치
    pip install pytest-mock
    
    # 또는 한 번에
    pip install pytest pytest-mock

    pyproject.toml에 개발 의존성으로 추가하는 것을 권장합니다.

    [project.optional-dependencies]
    dev = [
        "pytest>=8.0",
        "pytest-mock>=3.14",
    ]

    4. 사용 예시

    4-1. 기본 단위 테스트

    # src/calculator.py
    def add(a: int, b: int) -> int:
        return a + b
    
    def divide(a: float, b: float) -> float:
        if b == 0:
            raise ValueError("분모는 0이 될 수 없습니다.")
        return a / b
    # tests/test_calculator.py
    import pytest
    from src.calculator import add, divide
    
    class TestAdd:
        def test_positive_numbers(self):
            assert add(1, 2) == 3
    
        def test_negative_numbers(self):
            assert add(-1, -2) == -3
    
        @pytest.mark.parametrize("a, b, expected", [
            (0, 0, 0),
            (100, -100, 0),
            (1_000_000, 1, 1_000_001),
        ])
        def test_parametrized(self, a, b, expected):
            assert add(a, b) == expected
    
    class TestDivide:
        def test_normal_division(self):
            assert divide(10, 2) == 5.0
    
        def test_divide_by_zero(self):
            with pytest.raises(ValueError, match="분모는 0이 될 수 없습니다"):
                divide(10, 0)

    4-2. Fixture를 활용한 테스트

    # tests/conftest.py
    import pytest
    from myapp.models import User
    from myapp.database import SessionLocal
    
    @pytest.fixture
    def db_session():
        session = SessionLocal()
        yield session
        session.rollback()
        session.close()
    
    @pytest.fixture
    def sample_user(db_session):
        user = User(name="테스트유저", email="test@example.com")
        db_session.add(user)
        db_session.commit()
        return user
    # tests/test_user.py
    def test_user_exists(sample_user, db_session):
        found = db_session.query(User).filter_by(email="test@example.com").first()
        assert found is not None
        assert found.name == "테스트유저"

    4-3. pytest-mock 활용 예시

    # src/notification.py
    import smtplib
    
    class NotificationService:
        def send_email(self, to: str, subject: str, body: str) -> bool:
            with smtplib.SMTP("smtp.example.com") as server:
                server.sendmail("noreply@example.com", to, body)
            return True
    
        def notify_user(self, user_email: str, message: str) -> bool:
            return self.send_email(
                to=user_email,
                subject="알림",
                body=message
            )
    # tests/test_notification.py
    from src.notification import NotificationService
    
    def test_notify_user_calls_send_email(mocker):
        service = NotificationService()
    
        # send_email을 mock으로 대체
        mock_send = mocker.patch.object(service, "send_email", return_value=True)
    
        result = service.notify_user("user@example.com", "안녕하세요")
    
        assert result is True
        mock_send.assert_called_once_with(
            to="user@example.com",
            subject="알림",
            body="안녕하세요"
        )
    
    def test_notify_user_handles_failure(mocker):
        service = NotificationService()
        mocker.patch.object(service, "send_email", side_effect=Exception("SMTP 오류"))
    
        import pytest
        with pytest.raises(Exception, match="SMTP 오류"):
            service.notify_user("user@example.com", "테스트")

    5. 언제 사용하고 왜 좋은가

    5-1. pytest를 사용해야 하는 경우

    5-2. pytest-mock을 사용해야 하는 경우


    6. 두 라이브러리의 조합 활용

    pytest와 pytest-mock은 함께 사용할 때 시너지가 극대화됩니다. 아래는 실전에서 자주 활용되는 조합 패턴입니다.

    6-1. Fixture + mocker: 공통 Mock 객체 재사용

    # tests/conftest.py
    import pytest
    
    @pytest.fixture
    def mock_email_service(mocker):
        """재사용 가능한 mock email service fixture"""
        mock = mocker.patch("myapp.services.email.send_email")
        mock.return_value = True
        return mock
    
    @pytest.fixture
    def mock_payment_api(mocker):
        mock = mocker.patch("myapp.external.payment.charge")
        mock.return_value = {"status": "success", "transaction_id": "TX_001"}
        return mock
    # tests/test_order.py
    def test_order_sends_confirmation_email(mock_email_service, mock_payment_api):
        order = create_order(user_id=1, amount=50000)
        order.complete()
    
        mock_payment_api.assert_called_once()
        mock_email_service.assert_called_once_with(
            to="user@example.com",
            subject="주문 완료",
        )

    6-2. Parametrize + mocker: 여러 응답 케이스 테스트

    import pytest
    
    @pytest.mark.parametrize("api_response, expected_status", [
        ({"status": "success"}, "completed"),
        ({"status": "pending"}, "processing"),
        ({"status": "failed"}, "failed"),
    ])
    def test_payment_status_mapping(mocker, api_response, expected_status):
        mocker.patch("myapp.external.payment.get_status", return_value=api_response)
    
        result = PaymentService().check_status("TX_001")
    
        assert result == expected_status

    6-3. Fixture scope + mocker: 모듈 범위 Mock 설정

    @pytest.fixture(scope="module")
    def mock_feature_flag(mocker):
        """모듈 전체에서 feature flag를 활성화"""
        with mocker.patch("myapp.config.is_feature_enabled", return_value=True):
            yield

    6-4. spy + assert: 내부 동작 검증

    def test_cache_is_used_on_second_call(mocker):
        service = UserService()
        spy_db = mocker.spy(service, "_fetch_from_db")
    
        # 첫 번째 호출: DB에서 가져옴
        result1 = service.get_user(1)
        assert spy_db.call_count == 1
    
        # 두 번째 호출: 캐시에서 가져옴 (DB 미호출)
        result2 = service.get_user(1)
        assert spy_db.call_count == 1  # 여전히 1번만 호출됨
        assert result1 == result2

    6-5. 비동기(Async) 테스트

    # pip install pytest-asyncio
    import pytest
    
    @pytest.mark.asyncio
    async def test_async_api_call(mocker):
        mock_fetch = mocker.patch(
            "myapp.api.fetch_user",
            new_callable=mocker.AsyncMock,
            return_value={"id": 1, "name": "홍길동"}
        )
    
        result = await UserService().get_user_async(1)
    
        assert result["name"] == "홍길동"
        mock_fetch.assert_awaited_once_with(1)

    7. 모범 사례 및 팁

    • 테스트 파일 및 함수 네이밍 규칙
      • 파일: test_user_service.py
      • 클래스: TestUserService
      • 함수: test_create_user_with_valid_datatest_create_user_returns_id_when_email_is_valid
      • 테스트 이름은 what/when/then 구조로 작성하는 것을 권장합니다.
    • pytest는 기본적으로 test_*.py 또는 *_test.py 파일을 자동으로 발견합니다.
    • pytest.ini / pyproject.toml 설정
    • # pyproject.toml [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --tb=short" markers = [ "slow: 느린 테스트 (외부 API, DB 포함)", "integration: 통합 테스트", "unit: 단위 테스트", ]
    • mocker vs monkeypatch 선택 기준
    | 상황 | 권장 도구 | 이유 |
    | --- | --- | --- |
    | 함수/메서드 대체 및 호출 검증 | mocker.patch | assert_called_with 등 검증 API 풍부 |
    | 환경 변수 임시 변경 | monkeypatch.setenv | pytest 내장, 단순 값 변경에 적합 |
    | 딕셔너리/리스트 임시 수정 | monkeypatch.setitem | pytest 내장, 값 변경에 최적 |
    | 실제 동작 유지하며 호출 추적 | mocker.spy | spy_return, spy_exception 제공 |
    • 과도한 Mock 사용 주의사항
    • ⚠️Mock이 많을수록 테스트의 신뢰도가 낮아질 수 있습니다.
    • 모든 의존성을 Mock으로 대체하면 단위 테스트는 통과하더라도 실제 통합 환경에서 오류가 발생할 수 있습니다. 핵심 비즈니스 로직은 실제 의존성으로 테스트하는 통합 테스트도 병행하는 것을 권장합니다.

    8. 결론

    pytest와 pytest-mock은 현대 Python 개발에서 사실상 표준(de facto standard) 테스트 스택입니다.

    • pytest는 간결한 문법, 강력한 Fixture 시스템, 자동 테스트 발견으로 테스트 작성의 진입 장벽을 크게 낮춰줍니다.
    • pytest-mock은 외부 의존성 격리를 위한 Mock 패턴을 pytest의 방식으로 자연스럽게 통합해, unittest.mock을 직접 사용할 때의 boilerplate를 제거합니다.

    두 라이브러리를 함께 활용하면 빠르고 신뢰할 수 있으며 유지보수하기 쉬운 테스트 코드를 작성할 수 있습니다. 외부 의존성은 mock으로 격리하고, fixture로 설정을 공유하며, parametrize로 엣지 케이스를 망라하는 것이 핵심 원칙입니다.

    댓글