pytest 기초

MINERVA 12-0/12-1 테스트 패턴의 토대

MINERVA의 12-0편(테스트 진단)과 12-1편(고급 테스트 패턴)이 모두 pytest를 가정한다. 본 글은 fixture·parametrize·marker·monkeypatch·conftest·coverage·async 테스트까지 pytest 사용의 핵심을 정리한다. 12편을 읽기 전 또는 동시에 참조하면 패턴이 가볍게 읽힌다.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 06일

1 왜 pytest를 알아야 하는가

MINERVA 12-0편 테스트 전략 분석이 70+ 케이스를 진단하고, 12-1편 고급 테스트 패턴이 6개 패턴(Property·Snapshot·동시성·Contract·Mutation·CI 분리)을 다룬다. 이 글들이 모두 pytest 위에서 작성된다.

MINERVA 글이 사용하는 pytest 기능 본 글 절
@pytest.mark.integration 등 마커 마커 + CI 분리
monkeypatch.setattr(...) mock·monkeypatch
@pytest.mark.parametrize 매개변수화
@given (Hypothesis) Property-Based — pytest 위에서 동작
from fastapi.testclient import TestClient TestClient 통합

본 글은 그 토대를 한 호흡으로 정리한다. unittest와의 차이, fixture 작동 원리, marker로 CI 단계 분리, async 테스트까지 담는다.

2 첫 번째 테스트 — assert 한 줄로 끝

정의: pytest

pytest는 Python 표준 unittest보다 가벼운 문법으로 테스트를 작성하는 프레임워크다. 핵심 차이:

  • assert 한 줄: self.assertEqual(a, b) 대신 assert a == b
  • fixture 시스템: setUp/tearDown 대신 함수 기반 의존성 주입
  • 풍부한 플러그인: pytest-asyncio, pytest-cov, pytest-xdist 등
# tests/test_math.py
def test_addition():
    assert 1 + 1 == 2

def test_string_upper():
    assert "hello".upper() == "HELLO"

실행:

pytest                              # 모든 테스트
pytest tests/test_math.py            # 특정 파일
pytest tests/test_math.py::test_addition   # 특정 함수
pytest -k "string"                   # 함수명에 "string" 포함된 것만
pytest -v                            # 자세한 출력
pytest -x                            # 첫 실패에서 중단

assert 실패 시 pytest는 표현식을 분해해 어디가 어떻게 다른지 자동으로 보여준다 — unittest.assertEqual보다 디버깅이 직관적이다.

3 테스트 발견 규칙

pytest는 다음 패턴을 자동 발견한다 (별도 등록 없이).

발견 대상 패턴
디렉토리 tests/, 또는 현재 디렉토리부터 재귀
파일 test_*.py 또는 *_test.py
클래스 Test* (생성자 없는 클래스만)
함수·메서드 test_*
# tests/agents/qna_chatbot/test_agent.py — MINERVA 실제 구조
class TestConstructor:                    # 발견됨
    def test_default_args(self):           # 발견됨
        ...
    def test_invalid_input(self):
        ...

class HelperUtils:                         # 발견 안 됨 (Test로 시작 안 함)
    def test_something(self):
        ...

def test_module_level():                   # 모듈 단위 함수도 발견
    ...

MINERVA 12-0편의 70+ 케이스가 TestConstructor, TestTruncateLeadTail 같은 클래스로 그룹화된 이유 — 같은 도메인의 케이스를 묶어 가독성을 확보한다. 클래스는 단지 그룹핑 역할이고 self는 거의 안 쓴다.

4 fixture — 공유 setup의 함수형 모델

테스트마다 같은 객체를 만드는 게 반복되면 fixture로 추출한다.

import pytest

@pytest.fixture
def sample_query():
    """테스트용 Query 객체."""
    from core.contracts import Query
    return Query(text="테스트 질문", user_id="u-1")


def test_query_has_text(sample_query):    # 매개변수 이름이 fixture 이름
    assert sample_query.text == "테스트 질문"


def test_query_has_user(sample_query):
    assert sample_query.user_id == "u-1"

pytest는 함수 매개변수 이름을 보고 동명 fixture를 찾아 자동 주입한다. fixture 안에서 다른 fixture를 매개변수로 받을 수도 있어 의존성 그래프를 자연스럽게 구성한다.

4.1 fixture scope

같은 fixture를 매 테스트마다 새로 만들지 또는 한 번만 만들지 결정한다.

@pytest.fixture(scope="function")     # 기본값 — 테스트마다 새로
def fresh_agent():
    return QnaChatbotAgent(config=...)


@pytest.fixture(scope="module")       # 모듈당 한 번
def shared_retriever():
    return ParentChunkRetriever(...)  # 무거운 인덱스 로딩 1회


@pytest.fixture(scope="session")      # 전체 세션에 한 번
def azure_credentials():
    return load_credentials_once()

비싼 자원(벡터 인덱스, ALBERT weight)은 scope="module"이나 "session"으로 만들어 테스트 시간을 단축한다. 단 scope를 키울수록 테스트 간 부수효과 위험이 커지므로 immutable 자원에 한정한다.

4.2 fixture에서의 setup·teardown — yield 패턴

fixture에서 yield를 쓰면 yield 위는 setup, 아래는 teardown이다. async 컨텍스트 매니저와 같은 패턴이다 (Python async/await 참조).

@pytest.fixture
def temp_db():
    db = create_test_db()
    yield db                          # ← 테스트가 db를 받아 사용
    db.cleanup()                      # ← 테스트 종료 후 정리


def test_db_insert(temp_db):
    temp_db.insert({"id": 1})
    assert temp_db.count() == 1

5 parametrize — 한 테스트, 여러 입력

같은 로직을 여러 입력으로 검증할 때 반복문 대신 @pytest.mark.parametrize를 쓴다.

@pytest.mark.parametrize("text,expected", [
    ("hello", 5),
    ("", 0),
    ("한국어", 3),
    ("a" * 1000, 1000),
])
def test_string_length(text, expected):
    assert len(text) == expected

각 튜플이 별도 테스트로 발견되어 4개의 독립적인 케이스로 보고된다. 한 케이스가 실패해도 다른 케이스는 계속 실행된다 — 반복문 안 assert는 첫 실패에서 멈추는 것과 다르다.

$ pytest test_string_length -v
test_string_length[hello-5] PASSED
test_string_length[--0] PASSED
test_string_length[한국어-3] PASSED
test_string_length[a*1000-1000] PASSED

MINERVA 12-0편의 _truncate_with_lead_tail 경계값 테스트(365·366자) 같은 사례에 적합하다.

6 marker — CI 단계 분리의 핵심

마커는 테스트에 태그를 붙여 실행 시점에 필터링하는 도구다. 12-1편 CI 분리 표의 토대.

# tests/test_integration.py
import pytest

@pytest.mark.integration
def test_real_azure_search():
    """실제 Azure Search 호출 — 일 1회만."""
    ...

@pytest.mark.snapshot
def test_llm_response_schema():
    """실제 LLM 응답 회귀 — 주 1회만."""
    ...

pytest.ini 또는 pyproject.toml에 마커를 등록한다.

# pytest.ini
[pytest]
markers =
    integration: Azure/OpenAI 실제 호출 (일 1회)
    snapshot: LLM 응답 회귀 (주 1회)
    property: Hypothesis 속성 테스트
    concurrency: 동시성·thread safety
    contract: Agent ↔ Router 계약

실행 시 마커로 필터:

pytest                                       # 모든 테스트 (마커 무시)
pytest -m integration                         # integration만
pytest -m "not integration and not snapshot"  # 빠른 단위 테스트만
pytest -m "property or concurrency"           # 둘 중 하나

12-1편 CI 분리 전략 그대로다 — PR 시점은 외부 의존 0인 마커만, 일 1회는 integration, 주 1회는 snapshot.

7 mock·monkeypatch — 외부 의존 격리

테스트 대상이 외부 함수·객체에 의존하면 그것을 가짜로 바꾼다.

7.1 unittest.mock (표준 라이브러리)

from unittest.mock import patch, MagicMock

def test_run_with_mocked_agent():
    with patch("services.api.routers.qna_chatbot._build_agent") as mock_build:
        mock_agent = MagicMock()
        mock_agent.run.return_value = Response(text="답변", run_id="r1")
        mock_build.return_value = (mock_agent, None, None, {})

        from services.api.routers.qna_chatbot import run as router_run
        result = router_run(RunRequest(text="질문"))

        assert result.response.text == "답변"
        mock_build.assert_called_once()

patch가 컨텍스트 매니저로 동작해 with 블록 안에서만 mock이 적용되고 끝나면 원복된다.

7.2 monkeypatch (pytest 내장 fixture)

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("LLM_PROVIDER", "ollama")     # 환경변수 일시 변경
    monkeypatch.setattr("module.path.heavy_func",    # 함수 일시 교체
                        lambda *a: "fake_result")

    # 테스트 종료 후 자동 원복
    ...

MINERVA 12-0편의 스트리밍 에러 처리 테스트에서 monkeypatch.setattr(agent, "_prepare", ...)_prepare()를 가짜로 바꾸는 패턴이 정확히 이것이다.

7.3 둘의 차이

도구 자동 원복 scope 사용처
unittest.mock.patch with 블록 끝나면 컨텍스트 매니저 한 테스트의 일부 구간 mock
monkeypatch 테스트 함수 끝나면 pytest fixture 테스트 전체에서 일관된 mock

8 conftest.py — 공유 fixture 자동 발견

여러 테스트 파일에서 같은 fixture를 쓰려면 conftest.py에 정의한다. 별도 import 없이 모든 테스트가 자동으로 발견한다.

tests/
├── conftest.py                       # 모든 테스트 공유
├── agents/
│   ├── conftest.py                   # agents/ 하위만 공유
│   ├── qna_chatbot/test_agent.py
│   └── data_standardizer/test_agent.py
└── core/
    └── test_config.py
# tests/conftest.py
import pytest

@pytest.fixture
def sample_query():
    return Query(text="공통 테스트 질문")

@pytest.fixture(scope="session")
def cached_documents():
    return load_test_documents()       # 모든 테스트 세션에서 한 번만 로드

conftest.py가 위치한 디렉토리와 그 하위의 모든 테스트가 fixture를 사용할 수 있다. import는 불필요하다 — pytest가 자동 등록한다.

9 coverage — 어디까지 검증되는가

pytest-cov 플러그인으로 코드 커버리지를 측정한다.

pip install pytest-cov

pytest --cov=src --cov-report=term-missing --cov-report=xml
Name                              Stmts   Miss  Cover   Missing
-----------------------------------------------------------------
src/core/contracts.py                42      2    95%   58, 73
src/agents/qna_chatbot/agent.py     185     31    83%   142-146, 201, 245-260
-----------------------------------------------------------------
TOTAL                               227     33    85%

Missing 열이 검증되지 않은 라인이다. 100% 커버리지가 목표는 아니다 — 중요 분기와 예외 경로가 커버되는지가 핵심이다. 커버리지 90%지만 핵심 폴백 경로가 미검증인 경우가 더 위험하다.

GitHub Actions에 통합 (07-1편 GitHub Actions 참조):

- name: Run tests with coverage
  run: pytest --cov=src --cov-report=xml

- name: Upload to codecov
  uses: codecov/codecov-action@v5
  with:
    files: coverage.xml

10 async 테스트 — pytest-asyncio

async def 함수를 테스트하려면 pytest-asyncio 플러그인이 필요하다.

pip install pytest-asyncio
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"                  # async def 테스트 자동 인식
import pytest

async def test_async_endpoint():
    result = await fetch_data(42)       # await 사용 가능
    assert result["id"] == 42

@pytest.mark.asyncio                    # auto mode 아닐 때 명시
async def test_async_streaming():
    chunks = []
    async for chunk in event_generator():
        chunks.append(chunk)
    assert len(chunks) > 0

async def로 정의된 fixture도 자동 처리된다.

@pytest.fixture
async def async_client():
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client


async def test_with_async_client(async_client):
    response = await async_client.get("/health")
    assert response.status_code == 200

11 TestClient — FastAPI 통합 테스트

FastAPI는 TestClient로 라우터를 직접 호출할 수 있다. 실제 서버를 띄우지 않고도 HTTP 요청·응답 흐름을 검증한다.

from fastapi.testclient import TestClient
from services.api.main import app

client = TestClient(app)

def test_health_endpoint():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


def test_run_endpoint_with_mock(monkeypatch):
    """라우터를 통해 동기 흐름 검증, agent는 mock."""
    fake_response = Response(text="답변", run_id="r-1", model="gpt-4.1")
    monkeypatch.setattr(
        "services.api.routers.qna_chatbot._build_agent",
        lambda **k: (MagicMock(run=lambda q: fake_response), None, None, {})
    )

    resp = client.post("/agents/qna_chatbot/run", json={"text": "Q"})
    assert resp.status_code == 200
    assert resp.json()["response"]["text"] == "답변"

이 패턴이 12-1편 Contract 테스트의 토대다.

12 자주 발생하는 오류 패턴

WRONG:

@pytest.fixture
def query():
    return Query(text="Q")

def test_query():                      # 매개변수 누락
    assert query.text == "Q"           # NameError — query는 함수 객체

CORRECT:

def test_query(query):                 # 매개변수로 받아야 fixture가 주입됨
    assert query.text == "Q"

fixture는 매개변수 이름이 fixture 이름과 일치해야 자동 주입된다. 매개변수 없으면 fixture 함수 자체를 참조하게 되어 의도와 다르게 동작한다.

WRONG:

@pytest.fixture(scope="session")
def shared_list():
    return []                          # 세션 스코프 mutable

def test_a(shared_list):
    shared_list.append(1)
    assert len(shared_list) == 1

def test_b(shared_list):
    shared_list.append(2)
    assert len(shared_list) == 1       # 실패 — test_a가 1을 추가한 상태

CORRECT:

@pytest.fixture                        # 기본 function scope
def shared_list():
    return []                          # 매 테스트마다 새 리스트

mutable 객체를 큰 scope로 두면 테스트 간 부수효과가 누적된다. 큰 scope는 immutable 자원(설정 객체, DB 연결 등)에 한정한다.

WRONG:

@pytest.mark.slow                      # pytest.ini에 등록 안 함
def test_heavy():
    ...
PytestUnknownMarkWarning: Unknown pytest.mark.slow

CORRECT:

# pytest.ini
[pytest]
markers =
    slow: 느린 테스트 (1분 이상)

마커를 사용하기 전 pytest.inipyproject.toml에 등록해야 한다. 등록 없이 사용하면 경고가 나오고 pytest --strict-markers 옵션 시 실패한다.

WRONG:

async def test_endpoint():             # pytest-asyncio 미설치 또는 설정 안 됨
    result = await fetch()
    assert result["ok"]
RuntimeWarning: coroutine 'test_endpoint' was never awaited

CORRECT:

pip install pytest-asyncio
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"

async 테스트는 pytest-asyncio 플러그인 없이는 코루틴 객체만 만들고 실행하지 않는다. 플러그인 설치 + asyncio_mode 설정이 필요하다.

13 정리

항목 핵심
테스트 발견 test_*.py + Test* 클래스 + test_* 함수 자동 발견
fixture 매개변수 이름으로 자동 주입, scope로 재사용 단위 결정
parametrize 한 테스트 함수에 여러 입력 케이스를 독립 실행
marker CI 단계 분리의 핵심 — pytest -m "not integration" 같이 필터
mock unittest.mock.patch (구간 mock), monkeypatch (테스트 전체 mock)
conftest.py 디렉토리 단위 fixture 공유, import 불필요
coverage pytest-cov로 라인 커버리지 + GitHub Actions 통합
async pytest-asyncio + asyncio_mode=auto 필수
TestClient FastAPI 라우터 직접 호출, 실제 서버 없이 통합 테스트

14 응용 분야

MINERVA 시리즈 사용처 본 글 절
12-0 70+ 케이스 그룹화 테스트 발견 규칙 (Test* 클래스)
12-1 CI 6마커 분리 marker + CI 분리 전략
12-0 _FakeChain mock mock·monkeypatch (monkeypatch.setattr)
12-1 Contract 테스트 TestClient
12-1 Property-Based parametrize의 확장(Hypothesis가 동일 메커니즘)
16편 Checkpointing 테스트 async 테스트 (pytest-asyncio)

15 관련 주제

선행 학습

바로 이어 읽을 글 (Tier 1 다음 편)

  • Python typing 심화 (TypedDict·Annotated·Generic) — 작성 예정
  • 환경변수·.env 운영 — 작성 예정

MINERVA 시리즈 응용

Subscribe

Enjoy this blog? Get notified of new posts by email: