Devin.KR
로그인

파이썬 pytest 테스트 - fixture parametrize 모킹 실전 정리 (파이썬 고급 22단원)

개발자 조회 1

이 단원에서 배우는 것

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.raisesmatch 는 예외 메시지에 대한 정규식 검색이다. "예외가 났다"가 아니라 "의도한 이유로 났다"를 확인해야 한다. 오타 때문에 난 AttributeErrorException 으로는 통과해 버린다.

모킹 — 외부 호출 끊기

# 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.pyfrom 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"

기존 코드를 못 고칠 때만 monkeypatchfreezegun 을 쓴다. 난수도 같다. 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 없는 테스트를 양산한다.

스스로 확인하기

  1. 19단원의 probe()async def 다. pytest 로 어떻게 테스트하는가?
  2. 아래 테스트는 통과하지만 쓸모가 없다. 왜이고 어떻게 고치는가?
    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
  3. 파서를 21단원처럼 최적화하려 한다. 어떤 테스트가 있으면 안심하고 고칠 수 있는가? 세 종류를 들어라.

정답

  1. 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"
  2. 세 가지 문제가 있다. (1) 인자를 검증하지 않는다. slow_ratio 계산이 틀려서 엉뚱한 값을 보내도 통과한다. m.call_args 로 payload 를 확인해야 한다. (2) 반환값을 확인하지 않는다. runTrue 를 돌려주는지 봐야 한다. (3) autospec=True 가 없다. 시그니처가 바뀌어도 안 잡힌다. 그리고 assert m.called 보다 m.assert_called_once() 가 낫다. 두 번 불리는 버그를 잡아 준다.
  3. (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 로 쓸 수 있는 물건이 된다.