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

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로 옮길 때의 차이에서 다뤘습니다.
댓글
댓글 쓰기