Pytest 훅을 활용한 맞춤형 테스트 결과 보고서 작성

Pytest로 테스트 결과를 시각화할 때 흔히 pytest-html이나 allure-pytest 같은 플러그인을 활용합니다. 하지만 프로젝트 특성에 맞는 독자적인 보고서 형식이 필요한 경우, Pytest의 내부 훅(Hook) 메커니즘을 직접 활용하는 것이 유연한 해결책이 됩니다.

핵심 접근 방식

테스트 실행이 완료된 후 자동으로 HTML 보고서를 생성하려면 세 가지 요소가 필요합니다:

  1. 테스트 종료 시점에 개입할 수 있는 훅 함수 등록
  2. 실행 결과 데이터 추출 및 가공
  3. 템플릿 엔진을 통한 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 알림 등)으로 쉽게 확장할 수 있습니다.

태그: Pytest Hook Jinja2 test-report terminalreporter

7월 28일 13:32에 게시됨