파이썬 pytest 테스트 - fixture parametrize 모킹 실전 정리 (파이썬 고급 22단원)
이 단원에서 배우는 것
21단원에서 파서를 세 번 고쳐 쓰면서 매번 "결과가 같은지"를 눈으로 확인했다. 이런 확인은 세 번째쯤부터 하지 않게 되고, 그때 버그가 들어간다. 이 단원에서는 logkit 에 테스트를 붙여 고쳐도 안전한 상태를 만든다. 다음 단원의 패키징도 이 구조를 그대로 이어받는다.
- pytest 의
assert·parametrize·fixture로 중복 없이 경우의 수를 덮는다 tmp_path·monkeypatch·caplog로 파일·환경변수·로그를 안전하게 다룬다mock.patch로 외부 호출을 끊되, 패치 위치라는 최대 함정을 이해한다
이 단원의 코드는 pytest 8 이상, 파이썬 3.11 이상에서 그대로 돌아간다.
왜 필요한가
테스트를 "품질"이나 "신뢰" 같은 말로 정당화하면 잘 와닿지 않는다. 실질적인 이유는 이것이다. 테스트가 없으면 코드를 고칠 수 없다. 21단원에서 우리는 파서를 세 가지 버전으로 바꿔 보고 두 개를 버렸다. 버릴 수 있었던 이유는 결과가 같다는 확신이 있었기 때문이다. 그 확신이 없으면 개발자는 "돌아가는 코드는 건드리지 않는다"를 선택하고, 코드는 그 자리에서 굳는다.
두 번째 이유는 더 실무적이다. logkit 은 알림을 보낸다. 이 코드를 손으로 테스트하려면 실제로 알림이 나가야 한다. 임계값 계산이 맞는지 확인하려고 새벽에 슬랙 알림 30개를 쏠 수는 없다. 외부와의 경계를 끊고 로직만 확인하는 장치가 필요하고, 그게 모킹이다.
문법과 예제
대상 코드 구조
다음 단원의 패키징까지 염두에 두고 src 레이아웃으로 잡는다.
logkit/
├─ pyproject.toml
├─ src/logkit/
│ ├─ __init__.py
│ ├─ parser.py # 17단원: Entry, parse_line, parse, slow_ratio
│ ├─ notify.py # 알림 전송 (외부 HTTP)
│ ├─ report.py # 집계 + 임계값 판단 + 알림 호출
│ └─ cli.py
└─ tests/
├─ conftest.py
├─ test_parser.py
└─ test_report.py
# src/logkit/notify.py
from __future__ import annotations
import json
import urllib.request
def post_alert(endpoint: str, payload: dict) -> int:
body = json.dumps(payload).encode()
req = urllib.request.Request(
endpoint, data=body, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=5) as res:
return res.status
# src/logkit/report.py
from __future__ import annotations
from logkit.notify import post_alert
from logkit.parser import Entry, slow_ratio
def run(entries: list[Entry], endpoint: str, limit_ms: int, threshold: float) -> bool:
ratio = slow_ratio(entries, limit_ms)
if ratio < threshold:
return False
post_alert(endpoint, {"slow_ratio": round(ratio, 4), "limit_ms": limit_ms})
return True
assert 하나면 된다
# tests/test_parser.py
import pytest
from logkit.parser import parse, parse_line, slow_ratio
def test_parse_line_ok():
e = parse_line(
'10.0.0.1 - - [25/Aug/2026:10:00:00 +0900] "GET /api/orders HTTP/1.1" 200 812 143'
)
assert e is not None
assert (e.ip, e.path, e.status, e.ms) == ("10.0.0.1", "/api/orders", 200, 143)
assertEqual 류의 메서드가 필요 없다. pytest 는 테스트 모듈의 assert 문을 다시 써서(assertion rewriting) 실패 시 양쪽 값을 전부 보여 준다. 이 기능은 테스트 파일과 conftest.py, 그리고 등록된 플러그인에만 적용된다는 점을 기억해 두면 좋다. 헬퍼를 tests/helpers.py 로 빼면 그 안의 assert 는 친절한 출력이 안 나온다.
parametrize — 경우의 수를 표로 만든다
@pytest.mark.parametrize(
"raw",
[
"",
"쓰레기 줄",
'10.0.0.1 - - [25/Aug/2026] "GET /api/orders" 20 812 143', # 상태코드 2자리
],
)
def test_parse_line_reject(raw):
assert parse_line(raw) is None
@pytest.mark.parametrize(
("limit_ms", "expected"),
[(100, 0.75), (1000, 0.5), (5000, 0.0)],
)
def test_slow_ratio(entries, limit_ms, expected):
assert slow_ratio(entries, limit_ms) == pytest.approx(expected)
def test_slow_ratio_empty():
assert slow_ratio([], 1000) == 0.0 # 0 나누기 방어
for 루프로 케이스를 도는 것과 결정적으로 다르다. 케이스마다 독립된 테스트로 잡히므로, 세 번째만 실패했을 때 앞 두 개는 통과로 기록되고 실패 항목의 입력값이 리포트에 그대로 나온다. 루프였다면 첫 실패에서 멈춰 나머지는 실행조차 안 된다.
pytest.approx 는 부동소수점 비교에 반드시 쓴다. 0.1 + 0.2 == 0.3 이 거짓인 세계이므로, 비율·평균을 다루는 코드에서는 이걸 안 쓰면 언젠가 이유 없이 실패한다.
fixture — 준비 코드를 이름으로 부른다
# tests/conftest.py
import pytest
from logkit.parser import Entry
@pytest.fixture
def entries() -> list[Entry]:
return [
Entry("10.0.0.1", "/api/orders", 200, 143),
Entry("10.0.0.2", "/api/orders", 500, 2310),
Entry("10.0.0.3", "/api/users", 200, 88),
Entry("10.0.0.4", "/api/pay", 200, 1500),
]
@pytest.fixture
def access_log(tmp_path):
p = tmp_path / "access.log"
p.write_text(
'10.0.0.1 - - [25/Aug/2026:10:00:00 +0900] "GET /api/orders HTTP/1.1" 200 812 143\n'
"쓰레기 줄\n"
'10.0.0.2 - - [25/Aug/2026:10:00:01 +0900] "POST /api/orders HTTP/1.1" 500 0 2310\n',
encoding="utf-8",
)
return p
conftest.py 에 둔 fixture 는 임포트 없이 같은 디렉터리 이하의 모든 테스트에서 인자 이름만으로 쓸 수 있다. tmp_path 는 pytest 가 주는 내장 fixture 로, 테스트마다 새 디렉터리를 만들고 몇 회분을 남겨 뒀다가 지운다. 실패한 테스트가 만든 파일을 나중에 열어 볼 수 있다는 뜻이다.
def test_parse_skips_bad_lines(access_log):
with access_log.open(encoding="utf-8") as fp:
assert len(list(parse(fp))) == 2
정리가 필요한 fixture 는 yield 로 쓴다. 17단원의 컨텍스트 매니저와 같은 모양이다.
@pytest.fixture
def db_conn():
conn = connect(":memory:")
try:
yield conn
finally:
conn.close() # 테스트가 실패해도 닫힌다
monkeypatch — 환경변수와 전역 상태
20단원의 Settings 는 환경변수를 읽는다. 테스트에서 os.environ 을 직접 건드리면 그 변경이 다음 테스트까지 남는다. monkeypatch 는 테스트가 끝날 때 자동으로 되돌린다.
def test_settings_from_env(monkeypatch):
monkeypatch.setenv("LOGKIT_WORKERS", "8")
monkeypatch.delenv("LOGKIT_ENDPOINT", raising=False)
s = Settings()
assert s.workers == 8
with pytest.raises(ConfigError, match="LOGKIT_ENDPOINT"):
s.endpoint
pytest.raises 의 match 는 예외 메시지에 대한 정규식 검색이다. "예외가 났다"가 아니라 "의도한 이유로 났다"를 확인해야 한다. 오타 때문에 난 AttributeError 도 Exception 으로는 통과해 버린다.
모킹 — 외부 호출 끊기
# tests/test_report.py
from unittest.mock import patch
import pytest
from logkit import report
def test_alert_sent_when_over_threshold(entries):
with patch("logkit.report.post_alert", autospec=True) as m:
m.return_value = 200
sent = report.run(entries, "https://x.invalid/i", limit_ms=1000, threshold=0.4)
assert sent is True
m.assert_called_once()
endpoint, payload = m.call_args.args
assert endpoint == "https://x.invalid/i"
assert payload["slow_ratio"] == 0.5
def test_no_alert_under_threshold(entries):
with patch("logkit.report.post_alert", autospec=True) as m:
assert report.run(entries, "https://x.invalid/i", limit_ms=1000, threshold=0.9) is False
m.assert_not_called()
확인해야 할 것은 두 가지다. 호출됐는가, 그리고 무엇을 갖고 호출됐는가. 두 번째를 빼먹으면 임계값 계산이 틀려도 테스트는 통과한다.
autospec=True 를 습관으로 만든다. 이것 없이 만든 목은 아무 인자나 받아들인다. 나중에 post_alert 시그니처를 바꿔도 테스트는 계속 통과하고, 운영에서 TypeError 로 터진다. autospec 은 원본 시그니처를 그대로 흉내 내므로 인자 수가 틀리면 테스트에서 잡힌다.
실행
pip install -e ".[dev]" # 23단원에서 만들 설정. 패키지를 설치 상태로 만든다
pytest # 15 passed in 0.08s
pytest -k "ratio" # 이름으로 골라 실행
pytest -x --lf # 첫 실패에서 멈추고, 다음엔 실패한 것부터
pytest --durations=5 # 느린 테스트 5개
fixture 의 params·request·자동 사용(autouse)까지 포함한 전체 동작은 pytest fixture 공식 문서에 정리돼 있다.
실무에서 자주 틀리는 것
1. 패치를 정의된 곳에 건다 (최대 함정)
def test_patching_wrong_place(entries):
with patch("logkit.notify.post_alert", autospec=True) as m: # ← 정의된 곳
report.run(entries, "http://127.0.0.1:9/ingest", limit_ms=1000, threshold=0.4)
m.assert_not_called() # 목은 안 불렸다. 진짜 HTTP 가 나갔다
report.py 가 from logkit.notify import post_alert 를 했다면, 임포트 시점에 logkit.report.post_alert 라는 별도의 이름이 원본 함수를 가리키도록 만들어진 것이다. 나중에 logkit.notify.post_alert 를 바꿔도 report 모듈이 들고 있는 이름은 그대로다. 규칙은 하나다.
패치는 정의된 곳이 아니라 쓰이는 곳에 건다.
이 실수의 무서운 점은 테스트가 초록색으로 통과한다는 것이다. 목이 안 불렸으니 assert_called_once 를 안 쓴 테스트라면 실패 신호가 없고, 대신 CI 에서 실제 네트워크 호출이 나간다. 사내 슬랙에 테스트 알림이 쏟아지고 나서야 알게 된다. 위 예제처럼 닫힌 포트로 요청이 가면 예외가 나므로 이를 이용해 "진짜로 나갔음"을 테스트로 못박아 둘 수도 있다.
애초에 이런 실수가 불가능하도록 설계를 바꾸는 방법도 있다. 함수를 인자로 받는 것이다.
def run(entries, endpoint, limit_ms, threshold, sender=post_alert) -> bool:
...
sender(endpoint, payload)
# 테스트: 목 라이브러리가 필요 없다
calls = []
assert run(entries, "x", 1000, 0.4, sender=lambda e, p: calls.append((e, p)))
assert calls[0][1]["slow_ratio"] == 0.5
의존성 주입은 patch 문자열 경로에 의존하지 않으므로 리팩터링에 강하다. 모듈을 옮겨도 테스트가 안 깨진다.
2. fixture 의 scope 를 넓혀 놓고 상태를 공유한다
@pytest.fixture(scope="module") # 모듈당 한 번만 만든다
def entries():
return [Entry(...), ...] # 리스트는 가변이다
def test_a(entries):
entries.append(Entry("10.0.0.9", "/api/x", 500, 9999)) # 오염
def test_b(entries):
assert slow_ratio(entries, 1000) == 0.5 # 실패
test_b 는 단독으로 돌리면 통과하고 전체로 돌리면 실패한다. 디버깅하기 가장 괴로운 형태다. 게다가 pytest 는 순서를 바꿔 실행할 수 있으므로(-p no:randomly 를 안 쓰거나 병렬 실행 시) 실패가 간헐적으로 보인다. 규칙은 이렇다.
- 기본은
scope="function"(생략 시 기본값). 매 테스트마다 새로 만든다. - scope 를 넓히는 것은 만드는 데 실제로 오래 걸리는 것(DB 컨테이너, 대용량 픽스처 파일)에 한한다.
- 넓힌 fixture 가 돌려주는 값은 불변이어야 한다. 튜플이나
frozen=True데이터클래스로 만든다.
3. 시간·난수·현재 디렉터리에 의존한다
def test_daily_report():
result = build_report() # 내부에서 datetime.now() 를 쓴다
assert result["date"] == "2026-08-25" # 내일 실패한다
"어제까지 잘 되던 테스트가 오늘 깨졌다"의 절반은 시간이다. 자정 근처, 월말, 윤년, 서머타임 전환일에 몰려서 터진다. 해결은 시간을 인자로 받는 것이 가장 확실하다.
def build_report(now: datetime | None = None) -> dict:
now = now or datetime.now(timezone.utc)
...
def test_daily_report():
r = build_report(now=datetime(2026, 8, 25, 3, 0, tzinfo=timezone.utc))
assert r["date"] == "2026-08-25"
기존 코드를 못 고칠 때만 monkeypatch 나 freezegun 을 쓴다. 난수도 같다. random.seed(0) 을 부르는 것보다 Random(0) 인스턴스를 주입하는 편이 낫다. 전역 시드는 다른 테스트에 영향을 준다.
4. 커버리지 숫자를 목표로 삼는다
pytest --cov=logkit 로 나오는 수치는 실행된 줄의 비율일 뿐, 검증된 동작의 비율이 아니다. 아래 테스트는 slow_ratio 를 100% 커버하지만 아무것도 확인하지 않는다.
def test_slow_ratio_covers(entries):
slow_ratio(entries, 1000) # assert 가 없다. 커버리지는 100%
커버리지의 올바른 용법은 낮은 곳을 찾는 것이다. 90% 를 목표로 삼는 대신, 리포트에서 0% 인 모듈과 예외 처리 분기를 찾아 "여기는 왜 한 번도 안 도는가"를 묻는다. 죽은 코드를 발견하는 도구로 쓰면 값어치가 있고, KPI 로 쓰면 assert 없는 테스트를 양산한다.
스스로 확인하기
- 19단원의
probe()는async def다. pytest 로 어떻게 테스트하는가? - 아래 테스트는 통과하지만 쓸모가 없다. 왜이고 어떻게 고치는가?
def test_run_sends_alert(entries): with patch("logkit.report.post_alert") as m: report.run(entries, "https://x.invalid/i", limit_ms=1000, threshold=0.4) assert m.called - 파서를 21단원처럼 최적화하려 한다. 어떤 테스트가 있으면 안심하고 고칠 수 있는가? 세 종류를 들어라.
정답
- pytest 는 코루틴을 실행하지 못한다. 최신 pytest(8.4 이상)는
async def functions are not natively supported라며 실패로 처리하지만, 그 이전 버전은 경고만 남기고 통과로 기록했다. 테스트 본문이 한 줄도 실행되지 않았는데 초록색이 뜨는 것이라 훨씬 위험했다. 어느 쪽이든pytest-asyncio플러그인을 설치하고asyncio_mode = "auto"를 설정하거나, 각 테스트에@pytest.mark.asyncio를 붙인다. 플러그인 없이 가려면 동기 테스트 안에서asyncio.run(probe(...))을 직접 부르면 된다. 다만 이 방식은 fixture 도 async 로 쓸 수 없어서 금방 한계가 온다.# pyproject.toml [tool.pytest.ini_options] asyncio_mode = "auto" - 세 가지 문제가 있다. (1) 인자를 검증하지 않는다.
slow_ratio계산이 틀려서 엉뚱한 값을 보내도 통과한다.m.call_args로 payload 를 확인해야 한다. (2) 반환값을 확인하지 않는다.run이True를 돌려주는지 봐야 한다. (3)autospec=True가 없다. 시그니처가 바뀌어도 안 잡힌다. 그리고assert m.called보다m.assert_called_once()가 낫다. 두 번 불리는 버그를 잡아 준다. - (1) 정상 입력의 결과를 못박는 테스트 — 대표 로그 줄들이 정확한
Entry로 파싱되는지. (2) 경계·이상 입력 테스트 — 빈 줄, 깨진 줄, 필드 수가 다른 줄, 상태코드가 3자리가 아닌 줄. 최적화하다 정규식을 건드리면 여기서 가장 먼저 깨진다. (3) 구버전과 신버전의 결과가 같은지 비교하는 테스트. 대량의 실제 로그 샘플을 넣고 두 구현의 출력이 일치하는지 확인하는 방식이 최적화 작업에서는 가장 강력하다. 21단원에서slowest_paths세 버전을 비교한 것이 정확히 이것이었다.def test_optimized_matches_naive(access_log): lines = access_log.read_text(encoding="utf-8").splitlines() assert slowest_paths(lines, 1000) == slowest_paths_fast(lines, 1000)
다음 단원에서는 이 프로젝트를 배포 가능한 패키지로 만든다. pyproject.toml, 휠 빌드, logkit 명령어 등록까지 하고 나면 이 코드는 다른 사람이 pip install 로 쓸 수 있는 물건이 된다.