Pytest로 테스트 결과를 시각화할 때 흔히 pytest-html이나 allure-pytest 같은 플러그인을 활용합니다. 하지만 프로젝트 특성에 맞는 독자적인 보고서 형식이 필요한 경우, Pytest의 내부 훅(Hook) 메커니즘을 직접 활용하는 것이 유연한 해결책이 됩니다.
핵심 접근 방식
테스트 실행이 완료된 후 자동으로 HTML 보고서를 생성하려면 세 가지 요소가 필요합니다:
- 테스트 종료 시점에 개입할 수 있는 훅 함수 등록
- 실행 결과 데이터 추출 및 가공
- 템플릿 엔진을 통한 HTML 렌더링
결과 데이터 탐색하기
Pytest는 pytest_terminal_summary 훅을 통해 테스트 세션 종료 시 terminalreporter 객체를 전달합니다. 이 객체의 stats 속성에 분류된 테스트 결과가 저장되어 있습니다.
# conftest.py
def pytest_terminal_summary(reporter, status, cfg):
import pprint
pprint.pprint(reporter.stats.keys())
실행 후 확인 가능한 주요 키들은 다음과 같습니다:
passed- 성공한 테스트failed- 실패한 테스트skipped- 건너뛴 테스트xfailed- 예상된 실패xpassed- 예상과 리 성공error- 실행 중 오류
개별 테스트 결과 상세 정보
각 테스트 결과는 TestReport 객체로 표현됩니다. 핵심 속성을 살펴보면:
def pytest_terminal_summary(reporter, status, cfg):
sample = reporter.stats.get('failed', [None])[0]
if sample:
print(f"테스트 위치: {sample.location}")
print(f"노드 식별자: {sample.nodeid}")
print(f"소요 시간: {sample.duration}")
print(f"실패 원인: {sample.longrepr}")
print(f"캡처된 출력: {sample.sections}")
Jinja2 기반 보고서 생성
수집한 데이터를 HTML로 변환하는 완전한 구현 예시입니다:
# conftest.py
from pathlib import Path
from jinja2 import Environment, BaseLoader
REPORT_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{{ report_name }}</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
.summary { display: flex; gap: 1rem; margin-bottom: 2rem; }
.badge { padding: 0.5rem 1rem; border-radius: 4px; font-weight: 600; }
.passed { background: #d4edda; color: #155724; }
.failed { background: #f8d7da; color: #721c24; }
.skipped { background: #fff3cd; color: #856404; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid #dee2e6; }
th { background: #f8f9fa; font-weight: 600; }
.duration { color: #6c757d; font-size: 0.9em; }
pre { margin: 0; background: #f8f9fa; padding: 0.5rem; border-radius: 4px; }
</style>
</head>
<body>
<h1>{{ report_name }}</h1>
<div class="summary">
{% for label, count, cls in stats %}
<span class="badge {{ cls }}">{{ label }}: {{ count }}</span>
{% endfor %}
</div>
<table>
<thead>
<tr>
<th>모듈 경로</th>
<th>함수명</th>
<th>상태</th>
<th>실행 시간</th>
<th>출력/오류</th>
</tr>
</thead>
<tbody>
{% for case in cases %}
<tr>
<td>{{ case.module }}</td>
<td>{{ case.func }}</td>
<td><span class="badge {{ case.status_class }}">{{ case.status }}</span></td>
<td class="duration">{{ "%.3f"|format(case.time) }}s</td>
<td>
{% if case.output %}
<pre>{{ case.output }}</pre>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
"""
def pytest_terminal_summary(reporter, exit_code, config):
"""테스트 완료 후 자동으로 HTML 보고서 생성"""
# 결과 데이터 수집
all_cases = []
category_stats = []
for outcome in ['passed', 'failed', 'skipped', 'xfailed', 'xpassed', 'error']:
items = reporter.stats.get(outcome, [])
if not items:
continue
category_stats.append((outcome.upper(), len(items), outcome))
for item in items:
# setup/teardown 단계는 제외하고 call 단계만 처리
if hasattr(item, 'when') and item.when != 'call':
continue
module_path = item.location[0] if item.location else ''
func_name = item.location[2] if item.location else item.nodeid
# 출력 텍스트 추출
captured = ""
if item.sections:
for section_name, content in item.sections:
if 'stdout' in section_name or 'stderr' in section_name:
captured += content
all_cases.append({
'module': module_path,
'func': func_name,
'status': outcome,
'status_class': 'passed' if outcome in ('passed', 'xpassed') else
'failed' if outcome in ('failed', 'error') else 'skipped',
'time': getattr(item, 'duration', 0),
'output': captured.strip()[:500] # 최대 500자 제한
})
# 템플릿 렌더링
env = Environment(loader=BaseLoader())
template = env.from_string(REPORT_TEMPLATE)
html_content = template.render(
report_name=f"테스트 결과 보고서",
stats=category_stats,
cases=sorted(all_cases, key=lambda x: (x['status_class'], x['module']))
)
# 파일 저장
output_path = Path(config.rootdir) / "test_report.html"
output_path.write_text(html_content, encoding='utf-8')
print(f"\n보고서 생성 완료: {output_path.absolute()}")
확장 가능한 구조화
더 복잡한 요구사항을 위해 결과 데이터를 별도 클래스로 분리하면 유지보수가 워집니다:
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class TestOutcome:
identifier: str
file_path: str
test_name: str
result: str
elapsed_sec: float
captured_log: Optional[str]
failure_trace: Optional[str]
class ResultAggregator:
def __init__(self, reporter):
self.reporter = reporter
self.records: List[TestOutcome] = []
def collect(self):
for status in ['passed', 'failed', 'skipped', 'error']:
for entry in self.reporter.stats.get(status, []):
if getattr(entry, 'when', None) != 'call':
continue
self.records.append(TestOutcome(
identifier=entry.nodeid,
file_path=entry.location[0] if entry.location else '',
test_name=entry.location[2] if entry.location else entry.nodeid,
result=status,
elapsed_sec=entry.duration,
captured_log=self._extract_output(entry),
failure_trace=str(entry.longrepr) if entry.longrepr else None
))
return self
def _extract_output(self, entry):
outputs = []
for section, content in getattr(entry, 'sections', []):
outputs.append(f"[{section}]\n{content}")
return '\n'.join(outputs) if outputs else None
이러한 구조를 활용하면 템플릿과 데이터 처리 로직을 명확히 분리하여, 다양한 출력 형식(JSON, PDF, Slack 알림 등)으로 쉽게 확장할 수 있습니다.