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는 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 참조).
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] PASSEDMINERVA 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 플러그인으로 코드 커버리지를 측정한다.
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 참조):
10 async 테스트 — pytest-asyncio
async def 함수를 테스트하려면 pytest-asyncio 플러그인이 필요하다.
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) > 0async def로 정의된 fixture도 자동 처리된다.
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 자주 발생하는 오류 패턴
@pytest.fixture
def query():
return Query(text="Q")
def test_query(): # 매개변수 누락
assert query.text == "Q" # NameError — query는 함수 객체CORRECT:
fixture는 매개변수 이름이 fixture 이름과 일치해야 자동 주입된다. 매개변수 없으면 fixture 함수 자체를 참조하게 되어 의도와 다르게 동작한다.
@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:
mutable 객체를 큰 scope로 두면 테스트 간 부수효과가 누적된다. 큰 scope는 immutable 자원(설정 객체, DB 연결 등)에 한정한다.
PytestUnknownMarkWarning: Unknown pytest.mark.slow
CORRECT:
마커를 사용하기 전 pytest.ini나 pyproject.toml에 등록해야 한다. 등록 없이 사용하면 경고가 나오고 pytest --strict-markers 옵션 시 실패한다.
async def test_endpoint(): # pytest-asyncio 미설치 또는 설정 안 됨
result = await fetch()
assert result["ok"]RuntimeWarning: coroutine 'test_endpoint' was never awaited
CORRECT:
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 관련 주제
선행 학습
- Python async/await 기초 – async 테스트 작성의 토대
바로 이어 읽을 글 (Tier 1 다음 편)
- Python typing 심화 (TypedDict·Annotated·Generic) — 작성 예정
- 환경변수·.env 운영 — 작성 예정
MINERVA 시리즈 응용
- MINERVA 테스트 전략 분석 (12-0) – 70+ 케이스 진단
- MINERVA 고급 테스트 패턴 (12-1) – Property·Snapshot·Contract·Mutation
- MINERVA CI/CD GitHub Actions (07-1) – pytest를 워크플로에 통합