TIL/Claude Code

[TIL][Pytest] All Pass 알림은 거짓말이었다

아람2 2026. 5. 24. 20:00
반응형

E2E Automation 슬랙 채널에 ✅ All Pass 알림이 왔다 

리포트를 열어보니 Total Test Case 개수가 평소와 달랐다 

실행되지 않은 케이스가 있었다 

원인: Allure 결과 디렉터리 카운트 기반 집계 

결과 집계 스크립트는 Allure 결과 파일 개수로 passed/failed를 덮어쓰는 구조였다 

# 의도된 동작
passed = count_allure_results(status="passed")
failed = count_allure_results(status="failed")
total = passed + failed

pytest watchdog이 hang된 pytest를 SIGTERM으로 죽이는 순간, 진행 중·미실행 케이스는 Allure 결과 파일을 남기지 않는다

집계에서 빠진다

passed 100건이 통과 후 watchdog이 죽었다면 total=100, passed=100, failed=0으로 집계된다

"실행되었어야 할 N건"은 어디에도 없다

진행 중인 테스트를 추정이 아닌 사실로 기록

기존엔 watchdog이 stdout이나 log 파일을 휴리스틱으로 파싱해 "어떤 테스트가 hung 상태였는지" 추정했다

부정확하다

stdout은 PASSED/FAILED만 찍히고, log 파일은 마지막 줄이 Page Object의 INFO 로그라 테스트 이름이 안 나오는 경우가 많다

conftest.py에 hook을 추가해 매 테스트 진입/종료 시 파일에 직접 기록한다

import os
from pathlib import Path

@pytest.hookimpl(tryfirst=True)
def pytest_runtest_setup(item):
    phase = os.environ.get("WATCHDOG_PHASE_NAME")
    if not phase:
        return
    Path(f"reports/.watchdog_kills/.current_test_{phase}.txt").write_text(item.name)


@pytest.hookimpl(trylast=True)
def pytest_runtest_teardown(item):
    phase = os.environ.get("WATCHDOG_PHASE_NAME")
    if not phase:
        return
    flag = Path(f"reports/.watchdog_kills/.current_test_{phase}.txt")
    if flag.exists():
        flag.unlink()

WATCHDOG_PHASE_NAME이 export된 phase에만 동작한다

이제 watchdog이 어떤 테스트 도중에 죽였는지 추정이 아니라 사실로 안다

kill 이벤트를 진단 JSON으로 dump

watchdog이 SIGTERM을 보내기 직전에 진단 정보를 기록한다

diag = {
    "phase": phase,
    "reason": reason,                    # "log_stall" or "absolute_timeout"
    "pid": target_pid,
    "in_progress_test": read_current_test(phase),
    "in_progress_source": source,        # "hook"=fact, "heuristic"=guess
    "last_log_lines": tail_log_lines(200),
    "timestamp": datetime.now().isoformat(),
}
Path("reports/watchdog_diagnostic.json").write_text(json.dumps(diag))
Path(f"reports/.watchdog_kills/{phase}.flag").touch()

in_progress_source 필드로 사실(hook)/추정(heuristic)을 구분한다

이후 진단 리포트 HTML에서 배지로 시각화한다 (✅ hook / 🔍 heuristic)

{phase}.flag 파일은 집계 단계에서 "이 phase에서 watchdog kill이 있었음"을 알리는 시그널이다

집계 단계에서 run_aborted 플래그로 분기

결과 집계 스크립트가 flag 파일을 먼저 읽고, 있으면 missing 카운트를 계산한다

def collect_results():
    flag_files = list(Path("reports/.watchdog_kills/").glob("*.flag"))

    if flag_files:
        aborted_phase = parse_phase(flag_files[0])
        expected = get_expected_test_count(aborted_phase)
        completed = count_allure_results(phase=aborted_phase)
        missing = expected - completed
        run_aborted = True
    else:
        missing = 0
        run_aborted = False

    return {
        "passed": count_allure_results(status="passed"),
        "failed": count_allure_results(status="failed"),
        "missing": missing,
        "run_aborted": run_aborted,
    }

여기서 한 가지 함정이 있다

다른 phase의 stale flag를 흡수하지 않도록 timestamp staleness 가드를 추가했다

def load_run_abort_info():
    diag = json.load(Path("reports/watchdog_diagnostic.json").open())
    age_seconds = time.time() - parse_iso(diag["timestamp"]).timestamp()

    # 5분 이상 된 진단 정보는 무시 (이전 실행의 잔재)
    if age_seconds > 300:
        return None

    # 현재 실행 시작 시각 이후인지 추가 확인
    run_start = int(os.environ.get("SLACK_RUN_START_EPOCH", "0"))
    if parse_iso(diag["timestamp"]).timestamp() < run_start:
        return None

    return diag

smoke 실행이 이전 prod 실행의 stale 진단 정보를 흡수하던 회귀를 막는다

알림 메시지를 incomplete 분기로 전환

if run_info["run_aborted"]:
    title = f"🚨 Run Incomplete (미실행 {run_info['missing']}건)"
    body += format_watchdog_section(diag)
else:
    title = f"✅ All Pass {passed}/{failed}/{total}"

format_watchdog_section은 phase, reason, in-progress test, 마지막 로그 200줄을 슬랙 블록으로 포맷한다

이제 슬랙 알림이 거짓말을 하지 않는다

정리

항목 Before After
진행 중 테스트 식별 stdout/log heuristic 파싱 conftest hook으로 파일 기록
watchdog kill 시 집계 Allure 카운트로 덮어씀 run_aborted 플래그 + missing 산출
알림 제목 거짓 All Pass Run Incomplete (미실행 N건)
진단 정보 없음 phase / reason / in-progress test / 마지막 로그

Allure는 "통과한 것"만 안다

"실행되었어야 했지만 안 된 것"은 다른 출처에서 와야 한다

자동화 알림 시스템 자체도 검증 대상이다

반응형