pytest rerun failures, 재시도한 테스트 기록 남기는 법

pytest-rerunfailures 재시도 기록 남기는 법

3줄 요약

  • pytest-rerunfailures로 재시도를 걸어 CI 실패 알림을 줄였는데, "얼마나 자주 재시도됐는지"는 JUnit XML 어디에도 남지 않았습니다.
  • 원인은 pytest의 리포트 훅이 재시도 과정을 표준 리포트 형식에 담지 않기 때문이며, pytest-reportlog의 --report-log만 시도별 기록을 JSON 줄로 남깁니다.
  • 오늘 CI 설정에 --report-log=results.jsonl 한 줄을 추가하고, 글 안의 스크립트로 재시도율을 GitHub Actions 요약에 올려보시면 됩니다.

저희 팀은 두 달 전 pytest-rerunfailures를 붙였습니다. 새벽 배포 전 스모크 테스트가 네트워크 타임아웃으로 이틀에 한 번꼴로 실패해 팀원을 깨웠는데, --reruns 2를 넣고 나니 그 알림이 거의 사라졌습니다. 다들 만족했고 저도 한동안 잊고 지냈습니다. 그러다 분기 회고에서 팀장이 물었습니다. "지금 flaky한 테스트가 몇 개나 되나요? 재시도로 가려진 것 말고 실제로 불안정한 케이스를 세어서 보여주세요." CI가 이미 만들고 있는 JUnit XML을 열어 재시도 흔적을 찾았지만 없었습니다. 통과한 테스트는 그냥 통과한 것처럼만 보였습니다.

선택지는 크게 세 갈래였습니다. JUnit XML을 직접 뜯어고치거나, record_property 픽스처로 시도 횟수를 수동 기록하거나, 별도 플러그인이 표준 형식으로 남겨주는 로그를 쓰는 것이었습니다. 첫 번째는 유지보수 비용이 컸고, 두 번째는 테스트마다 코드를 추가해야 해서 팀 전체에 강제하기 어려웠습니다.

결론부터 말씀드리면 세 번째, pytest-reportlog의 --report-log 옵션이 가장 적은 코드로 가장 정확한 결과를 냈습니다. 이 글에서는 JUnit XML이 왜 재시도를 감추는지 직접 재현하고, report-log에서 flaky율을 뽑아 GitHub Actions 요약에 올리는 과정, 재시도 자체에 있던 상태 격리 버그까지 정리합니다.

1. 재시도를 걸면 CI 로그에서 무엇이 사라지는가

1-1. pytest 리포트 훅과 재시도의 관계

pytest는 테스트 하나를 세 단계(setup, call, teardown)로 나눠 각 단계마다 pytest_runtest_logreport라는 훅을 호출합니다. JUnit XML을 만드는 내장 플러그인도, 터미널에 초록·빨강 점을 찍는 것도 이 훅을 구독해서 동작합니다. 문제는 pytest-rerunfailures가 실패한 테스트를 다시 돌릴 때, 훅은 매 시도마다 불리지만 JUnit XML 플러그인은 같은 테스트 노드에 대해 마지막 리포트로 이전 것을 덮어써 버린다는 점입니다. 파일에는 시도 횟수 없이 최종 결과 하나만 남습니다. 이 동작은 2019년에 GitHub 이슈로 보고되었고, 저희가 오늘 최신 버전으로 다시 확인한 결과도 같았습니다.

1-2. 실측: 3번 재시도했는데 XML엔 1줄만 남았습니다

말로 설명하는 대신 직접 돌려봤습니다. 환경은 pytest 9.1.1, pytest-rerunfailures 16.6(PyPI 기준 최신)입니다. 두 번 실패하고 세 번째에 통과하는 테스트 하나와, 항상 통과하는 테스트 하나를 같이 돌렸습니다.

# 재시도 3회, JUnit XML과 report-log를 동시에 남깁니다
pytest test_flaky.py -v --reruns 3 \
  --junitxml=results.xml \
  --report-log=results.jsonl

터미널 로그에는 RERUN이 두 번, 마지막에 PASSED가 찍혀 세 번 시도했다는 사실이 분명히 보였습니다. 그런데 같은 실행에서 나온 results.xml을 열어보면 이렇습니다.

<testsuite name="pytest" tests="2" ...>
  <testcase classname="test_flaky" name="test_eventually_passes" time="0.002" />
  <testcase classname="test_flaky" name="test_always_passes" time="0.000" />
</testsuite>

재시도를 알려주는 속성이나 자식 태그가 하나도 없습니다. test_eventually_passes는 두 번 실패했다가 세 번째에 통과했는데도, XML만 보면 처음부터 깨끗하게 통과한 테스트와 구분이 안 됩니다. 이 XML을 그대로 대시보드에 태우면 "이번 주 실패율 0%"라는 숫자가 나오지만, 실제로는 재시도 예산을 이미 절반 넘게 써버린 상태일 수 있습니다.

2. 대안 셋 중 무엇이 표준에 가까운가

2-1. record_property, 옛 resultlog, report-log 비교

세 방법을 실무 기준으로 비교하면 아래와 같습니다.

방법 시도별 기록 코드 수정 필요 현재 상태
record_property 픽스처 테스트마다 직접 기록해야 함 테스트 코드마다 추가 지원되지만 강제할 방법이 없음
--resultlog (pytest 내장) 이론상 가능 불필요 pytest 4.0에서 지원 중단 예고, 6.0에서 완전히 제거됨
pytest-reportlog의 --report-log 시도마다 자동 기록 불필요, 플래그만 추가 별도 패키지로 유지보수 중, 오늘 기준 1.0.0

record_property는 유연하지만 "모든 팀원이 항상 이 픽스처를 쓴다"는 전제가 있어야 지표가 완성됩니다. 신입이 테스트를 새로 짜면서 이걸 빠뜨리면 그 테스트만 조용히 지표에서 빠집니다. 반면 --report-log는 CI 명령어 한 줄에만 추가하면 이후 작성되는 모든 테스트에 자동 적용됩니다. 팀 규모가 4~6명이고 자동화 초기 단계라면 사람이 지켜야 하는 규칙보다 명령줄 옵션 하나로 끝나는 쪽이 안전합니다.

2-2. report-log가 남기는 줄의 구조

--report-log=results.jsonl을 켜면 세션 시작부터 종료까지 일어난 일이 한 줄에 하나씩 JSON으로 쌓입니다. 같은 테스트를 재시도로 돌린 실제 출력에서 call 단계만 추리면 이렇게 나옵니다.

{"$report_type": "TestReport", "when": "call", "nodeid": "test_flaky.py::test_eventually_passes", "outcome": "rerun"}
{"$report_type": "TestReport", "when": "call", "nodeid": "test_flaky.py::test_eventually_passes", "outcome": "rerun"}
{"$report_type": "TestReport", "when": "call", "nodeid": "test_flaky.py::test_eventually_passes", "outcome": "passed"}

같은 nodeid가 세 줄 등장하고, 실패해서 다시 돈 시도의 outcome은 "rerun"으로 따로 표시됩니다. 자주 쓰는 필드만 정리하면 아래와 같습니다.

필드 의미 이 예시에서의 값
$report_type 이벤트 종류 TestReport
when 테스트 단계 setup / call / teardown
nodeid 테스트 식별자 test_flaky.py::test_eventually_passes
outcome 이 단계의 결과 passed / failed / rerun

nodeid별로 이 줄 수를 세면 시도 횟수가, 마지막 줄만 passed인 케이스를 세면 flaky 여부가 나옵니다. 다만 teardown 리포트는 재시도 횟수와 무관하게 마지막 한 번만 찍혔습니다. 정리(cleanup) 로그를 시도별로 추적하려는 목적이라면 이 부분은 report-log로도 채워지지 않는다는 점을 감안해야 합니다. 비슷한 결의 사례가 이슈 #237에도 있습니다.

3. report-log로 flaky율을 뽑아 GitHub Actions 요약에 올리기

3-1. 파싱 스크립트

call 단계 리포트만 걸러서 nodeid별 시도 횟수를 세면 됩니다. 실제로 위 실측 데이터에 돌려서 나온 결과까지 아래에 같이 적었습니다.

# report-log(JSON Lines)에서 테스트별 시도 횟수를 세어 flaky 지표를 뽑습니다.
# 재시도된 시도는 outcome이 "rerun"으로 찍히므로 call 단계 줄 수만 세면 됩니다.
import json
import sys
from collections import defaultdict

attempts = defaultdict(list)  # nodeid -> [outcome, outcome, ...]

with open(sys.argv[1], encoding="utf-8") as f:
    for line in f:
        rec = json.loads(line)
        if rec.get("$report_type") == "TestReport" and rec.get("when") == "call":
            attempts[rec["nodeid"]].append(rec["outcome"])

rows = [(nodeid, len(outcomes), outcomes[-1]) for nodeid, outcomes in attempts.items()]
flaky = [r for r in rows if r[1] > 1]

print("| 테스트 | 시도 횟수 | 최종 결과 |")
print("| --- | --- | --- |")
for nodeid, tries, final in sorted(rows, key=lambda r: -r[1]):
    print(f"| `{nodeid}` | {tries} | {final} |")
print()
rate = len(flaky) / len(rows) * 100 if rows else 0
print(f"전체 {len(rows)}개 중 재시도 발생 {len(flaky)}개, flaky rate {rate:.1f}%")

실측 파일에 돌리면 test_eventually_passes가 3회 시도로 잡히고, 전체 2개 중 1개가 재시도돼 flaky rate 50.0%로 나옵니다. 팀 전체 스위트로 확장하면 회고 시간에 바로 쓸 수 있는 숫자가 됩니다.

3-2. GitHub Actions 요약에 표로 올리기

GitHub Actions는 각 스텝이 GITHUB_STEP_SUMMARY 환경변수가 가리키는 파일에 마크다운을 append하면, 잡이 끝날 때 그 내용을 모아 실행 화면 상단 요약으로 보여줍니다. GFM을 지원하므로 표를 그대로 붙일 수 있습니다.

# 테스트를 report-log와 함께 돌리고, 스크립트 출력을 그대로 요약에 붙입니다
- name: 테스트 실행 (report-log 포함)
  run: pytest --reruns 3 --report-log=results.jsonl
- name: flaky 지표를 요약에 올리기
  if: always()
  run: python3 flaky_rate.py results.jsonl >> "$GITHUB_STEP_SUMMARY"

주의할 점이 두 가지 있습니다. 스텝별 요약은 1MiB로 제한되고, 한 스텝이 끝나 요약이 올라간 뒤에는 다음 스텝이 그 내용을 고칠 수 없습니다. 두 스텝에 걸쳐 같은 표를 이어 쓰면 앞부분은 남고 뒷부분만 새 블록으로 추가되니, 한 스텝 안에서 끝내는 편이 안전합니다.

4. 재시도 인프라 자체의 숨은 함정

최근 세 버전의 변경 사항을 PyPI 배포일 기준으로 정리하면 아래와 같습니다. 이 글의 실측도 이 중 가장 최신인 16.6으로 진행했습니다.

버전 배포일 핵심 변경
16.4 2026-07-01 --reruns-delay-backoff-factor 추가, pytest 9.0+ subtest 재시도 지원
16.5 2026-08-13 재시도마다 새 인스턴스 생성(격리 버그 수정), --max-suite-reruns 추가
16.6 2026-08-17 --rerun-exclude-path 추가, 16.5의 --pdb 회귀 수정

4-1. 첫 번째 함정 — 조용히 사라지는 재시도 흔적

지금까지 다룬 내용이 이 글의 첫 번째 함정입니다. JUnit XML만 보고 품질을 판단하면 재시도로 겨우 통과한 테스트와 원래 안정적인 테스트를 구분할 방법이 없습니다. 이 함정의 무서운 점은 에러가 나지 않는다는 데 있습니다. 파이프라인은 초록불이고 대시보드 숫자도 멀쩡해서, 아무도 알아차리지 못한 채 몇 달이 지날 수 있습니다.

4-2. 가장 비싼 함정 — self가 기억하는 실패

두 번째는 더 비쌉니다. pytest-rerunfailures 16.5 이전 버전에서는 클래스 기반 테스트를 재시도할 때 실패했던 시도의 인스턴스를 그대로 재사용했습니다. self에 저장해 둔 상태가 재시도로 새어 들어가 테스트 격리가 깨졌다는 사실이 변경 이력에 명시돼 있습니다. 저희 팀도 비슷한 증상을 겪었습니다. 셋업에서 self.session = requests.Session()을 쓰는 클래스 기반 테스트가 있었는데, 첫 시도에서 세션에 이상한 쿠키가 실려 실패하면 재시도에서도 같은 세션 객체를 물려받아 계속 실패했습니다. 애플리케이션 버그로 의심하고 하루를 썼는데, 나중에 보니 재시도 메커니즘 자체의 상태 누수였습니다. 제 해석으로는 이런 함정이 JUnit XML 문제보다 위험한 이유는, 겉보기엔 "진짜 버그"처럼 보여 엉뚱한 곳을 며칠씩 뒤지게 만들기 때문입니다. 16.5 이상으로 올리면 재시도마다 새 인스턴스를 만들어 해결됩니다.

4-3. 재시도 예산에 상한을 걸기

같은 16.5에는 --max-suite-reruns 옵션도 함께 들어왔습니다. 스위트 전체에서 허용할 총 재시도 횟수를 정해 두면, 마킹된 flaky 테스트가 갑자기 무더기로 실패하기 시작해도 CI 시간이 끝없이 늘어나는 일을 막을 수 있습니다.

# pytest.ini — 테스트당 최대 3회, 스위트 전체로는 최대 30회까지만 재시도
[pytest]
reruns = 3
reruns_delay = 2
max_suite_reruns = 30

이 값을 얼마로 잡을지는 스위트 크기에 달려 있지만, 저희는 "평소 재시도 건수의 3배"를 상한으로 잡았습니다. 그 이상 몰린다면 개별 테스트의 우연한 불안정이 아니라 환경 자체의 문제로 보는 편이 맞다고 판단했습니다.

4-4. 도입 난이도로 정리하면

세 가지 대응을 도입 난이도·유지보수 비용·팀 학습곡선 기준으로 정리하면 아래와 같습니다.

대응 도입 난이도 유지보수 비용 팀 학습곡선
--report-log + 파싱 스크립트 낮음 (플래그 1개 + 스크립트 1개) 낮음 (스크립트는 한 번 짜면 거의 안 바뀜) 낮음 (읽는 사람은 표만 보면 됨)
record_property 수기 기록 중간 (테스트마다 추가) 높음 (신규 테스트마다 빠뜨릴 위험) 중간 (작성자 전원이 규칙을 알아야 함)
pytest-rerunfailures 16.5+ 업그레이드 낮음 (버전 고정만 바꾸면 됨) 낮음 낮음
--max-suite-reruns 상한 설정 낮음 (설정 한 줄) 중간 (상한값을 주기적으로 재산정해야 함) 낮음

5. 권할 만한 경우 / 권하지 않는 경우

상황 판단
CI가 이미 pytest를 쓰고, flaky 지표를 대시보드나 회고에 정기적으로 올리고 싶다 권합니다 — --report-log 도입 비용이 매우 낮습니다
클래스 기반 테스트에서 self에 상태를 저장하며 pytest-rerunfailures를 16.4 이하로 쓰고 있다 권합니다(업그레이드) — 원인 불명의 재시도 실패를 겪기 전에 16.5 이상으로 올리는 편이 쌉니다
팀이 아직 pytest 자체를 도입하지 않았거나, 재시도 자체를 쓰지 않는다 권하지 않습니다 — 지표를 뽑을 재시도 데이터 자체가 없습니다
이미 자체 리포팅 파이프라인(Allure, ReportPortal 등)이 시도별 기록을 남기고 있다 권하지 않습니다 — 중복 계측입니다. 기존 파이프라인의 재시도 필드를 먼저 확인하십시오

report-log는 만능이 아닙니다. teardown이 시도별로 안 찍힌다는 한계가 있고, JSON Lines 형식이라 별도 파싱 코드가 필요합니다. 그래도 팀 규모가 크지 않다면 사람이 지켜야 하는 규칙보다 명령줄 옵션 하나로 끝나는 이 방법이 실패할 여지가 적습니다.

내일 바로 적용하시려면 아래 두 줄을 CI 설정에 더하고, 위 3-1의 스크립트를 저장소에 커밋해 두시면 됩니다.

# requirements 또는 pyproject에 추가
pip install "pytest-rerunfailures>=16.5" pytest-reportlog

재시도 기록을 리포트에서 추세로 보려면 Allure Report 히스토리를 CI에서 유지하는 방법을 참고하시면 됩니다. API 테스트 쪽 JUnit 리포트는 Newman에서 Postman CLI로 옮길 때의 차이에서 다뤘습니다.

댓글

이 블로그의 인기 게시물

옵시디언 3기기 동기화, 유료 결제 없이 구글 드라이브로 끝내기

최저가 주유소, 얼마나 돌아가면 손해일까?

회의시간 줄이는 법, 개수보다 배치 먼저

유류세 인하 2개월 연장, 주유소 가격 실제 인하폭 계산

국민연금 추납 조회, 미루면 얼마나 더 낼까?