Python API 테스트 결과를 CSV・HTML 리포트로 출력하는 방법|pytest×requests 실무 패턴

테스트 자동화

Python으로 API 테스트를 자동화해도 테스트 결과를 공유할 수 없다면 실무에서는 충분하다고 할 수 없습니다.

이 글에서는 pytest・requests를 사용한 API 테스트 결과를 HTML 리포트(pytest-html)와 CSV 파일 양쪽으로 자동 출력하는 방법을 해설합니다. 실무 QA 엔지니어가 그대로 사용할 수 있는 코드와 함께 소개합니다.

📌 이 글은 이런 분께 추천합니다

  • API 테스트 결과를 HTML 리포트나 CSV로 출력하고 싶은 QA 엔지니어
  • pytest-html 사용법을 배우고 싶은 분
  • 테스트 결과를 포트폴리오나 클라이언트 보고서 에비던스로 활용하고 싶은 분
  • Python으로 CSV에 테스트 결과를 쓰는 구현 방법을 알고 싶은 분

이 글을 읽으면 알 수 있는 것

  • pytest-html로 HTML 리포트를 자동 생성하는 설정 방법
  • Python의 csv 모듈로 API 테스트 결과를 CSV에 쓰는 방법
  • 테스트 결과에 커스텀 정보(일시・상태・응답 시간)를 추가하는 방법
  • 포트폴리오・클라이언트 보고에 사용할 수 있는 에비던스 만드는 방법

👨‍💻 필자 소개

QA 엔지니어로서 실무에서 Python・pytest・requests를 사용한 API 테스트 자동화를 담당하고 있습니다. 본 글에서 사용하는 코드는 모두 GitHub에 공개되어 있으며, 실제로 동작 확인이 완료된 코드를 그대로 해설하고 있습니다. GitHub에서 코드 보기 →


00. API 테스트 리포트・CSV 출력이 중요한 이유

테스트를 실행하는 것만으로는 충분하지 않습니다. 실무에서는 결과를 기록・공유하는 구조를 만드는 것이 중요합니다.

출력 형식용도장점
HTML 리포트테스트 결과 시각화브라우저에서 보기 편함・GitHub에서 공유하기 쉬움
CSV 파일데이터 기록・분석Excel에서 열 수 있음・과거 결과와 비교 가능
터미널 출력즉시 확인개발 중 디버깅에 편리

💡 포인트:포트폴리오로 공개한다면 HTML 리포트를 GitHub에 포함하는 것만으로 단번에 프로답게 보입니다. 클라이언트 보고에도 그대로 사용할 수 있습니다.


01. pytest + requests의 API 테스트 리포트 환경

아직 환경 구축이 안 된 분은 먼저 설치해주세요.

pip install requests pytest pytest-html
라이브러리역할
pytest테스트 실행・관리
pytest-htmlHTML 리포트 자동 생성
csv(표준 라이브러리)CSV 파일에 쓰기(추가 설치 불필요)

02. Python으로 API 테스트 HTML 리포트를 자동 생성하는 방법

pytest.ini 설정(1회만)

아래 설정을 프로젝트 루트에 두기만 하면 pytest를 실행할 때마다 HTML 리포트가 자동 생성됩니다. 한 번 설정하면 추가 코드는 불필요합니다.

# pytest.ini
[pytest]
addopts = --html=report.html --self-contained-html

이 설정만으로 테스트 실행 때마다 HTML 리포트가 자동 생성됩니다.

테스트 실행 커맨드

# HTML 리포트를 자동 생성하면서 실행
pytest test_api.py -v

실행 후 폴더 구성

project/
├── pytest.ini
├── test_api.py
└── report.html   ← 자동 생성됨!

💡 포인트:--self-contained-html 을 붙이면 CSS나 이미지가 HTML에 내장되어 파일 하나로 완결됩니다. GitHub 업로드나 클라이언트와의 공유를 그대로 할 수 있습니다.


03. Python으로 API 테스트 결과를 CSV에 쓰는 방법

Python의 csv 모듈은 표준 라이브러리이므로 추가 설치가 불필요합니다. 이 모듈을 사용하면 테스트 결과를 손쉽게 CSV 파일에 저장할 수 있습니다.

기본적인 CSV 출력 코드

Python의 csv 모듈과 datetime 모듈을 조합하면 테스트 ID・테스트 명・결과・상태 코드・응답 시간을 1행씩 CSV에 쓸 수 있습니다.

import csv
import datetime
import requests

BASE_URL = "https://jsonplaceholder.typicode.com"
CSV_FILE = "test_results.csv"

def write_result_to_csv(tc_id, test_name, status, status_code, response_time_ms, memo=""):
    """테스트 결과를 CSV에 추가 기록한다"""
    # ✅ utf-8-sig: Excel에서 CSV를 열어도 깨지지 않음
    with open(CSV_FILE, mode="a", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)
        writer.writerow([
            datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            tc_id,
            test_name,
            status,
            status_code,
            f"{response_time_ms:.0f}ms",
            memo
        ])

def test_get_user_with_csv():
    """TC01: GET 테스트 + CSV 출력"""
    # ✅ timeout=5: API가 다운되어도 영원히 기다리지 않음
    # ✅ response.elapsed: requests 공식 응답 시간 취득 기능(더 정확)
    response = requests.get(f"{BASE_URL}/users/1", timeout=5)
    elapsed_ms = response.elapsed.total_seconds() * 1000

    try:
        assert response.status_code == 200
        write_result_to_csv("TC01", "GET /users/1", "PASS", response.status_code, elapsed_ms)
        print(f"\n✅ TC01 PASS | {elapsed_ms:.0f}ms")
    except AssertionError:
        write_result_to_csv("TC01", "GET /users/1", "FAIL", response.status_code, elapsed_ms,
                            f"기댓값:200 실제:{response.status_code}")
        raise

💡 포인트:mode="a" 를 사용하면 추가 기록 모드가 됩니다. 테스트를 실행할 때마다 결과가 추가되어 과거의 실행 이력을 남길 수 있습니다.


04. Python으로 API 테스트 CSV 헤더를 자동 작성하는 방법

CSV 파일이 존재하지 않을 때만 헤더를 쓰는 처리를 추가합니다. os.path.exists() 로 파일 존재를 확인하고 나서 쓰면 테스트를 여러 번 실행해도 헤더가 중복되지 않습니다.

import csv
import os

CSV_FILE = "test_results.csv"

def initialize_csv():
    """CSV 파일이 존재하지 않는 경우 헤더를 쓴다"""
    if not os.path.exists(CSV_FILE):
        # ✅ utf-8-sig: Excel에서 CSV를 열어도 깨지지 않음
        with open(CSV_FILE, mode="w", newline="", encoding="utf-8-sig") as f:
            writer = csv.writer(f)
            writer.writerow([
                "실행 일시",
                "테스트 ID",
                "테스트 명",
                "결과",
                "상태 코드",
                "응답 시간",
                "비고"
            ])

💡 포인트:os.path.exists() 로 파일 존재 확인을 함으로써 2회째 이후의 실행에서 헤더가 중복되지 않게 할 수 있습니다.


05. Python으로 API 테스트 리포트화 전체 코드

HTML 리포트와 CSV 출력을 조합한 완전판 코드입니다. fixture로 CSV를 초기화하고 timeout・response.elapsed・utf-8-sig 등 실무 패턴을 전부 반영하고 있습니다.

"""
API Test with CSV Output & HTML Report
Target: JSONPlaceholder (https://jsonplaceholder.typicode.com)
Framework: Python + requests + pytest + pytest-html
"""
import csv
import os
import datetime
import requests
import pytest

BASE_URL = "https://jsonplaceholder.typicode.com"
CSV_FILE = "test_results.csv"


def initialize_csv():
    """CSV 파일이 존재하지 않는 경우 헤더를 쓴다"""
    if not os.path.exists(CSV_FILE):
        # ✅ utf-8-sig: Excel에서 CSV를 열어도 깨지지 않음
        with open(CSV_FILE, mode="w", newline="", encoding="utf-8-sig") as f:
            writer = csv.writer(f)
            writer.writerow(["실행 일시", "테스트 ID", "테스트 명", "결과",
                              "상태 코드", "응답 시간", "비고"])


# ✅ fixture로 CSV를 초기화(pytest 시작 시 1회만 실행)
@pytest.fixture(scope="session", autouse=True)
def setup_csv():
    initialize_csv()


def write_result(tc_id, test_name, status, status_code, elapsed_ms, memo=""):
    """테스트 결과를 CSV에 추가 기록한다"""
    # ✅ utf-8-sig: Excel에서 CSV를 열어도 깨지지 않음
    with open(CSV_FILE, mode="a", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)
        writer.writerow([
            datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            tc_id,
            test_name,
            status,
            status_code,
            f"{elapsed_ms:.0f}ms",
            memo
        ])


def test_get_user():
    """TC01: GET 테스트"""
    # ✅ timeout=5: API가 다운되어도 영원히 기다리지 않음
    # ✅ response.elapsed: requests 공식 응답 시간 취득 기능
    response = requests.get(f"{BASE_URL}/users/1", timeout=5)
    elapsed_ms = response.elapsed.total_seconds() * 1000

    try:
        assert response.status_code == 200
        assert response.json()["id"] == 1
        write_result("TC01", "GET /users/1", "PASS", response.status_code, elapsed_ms)
        print(f"\n✅ TC01 PASS | {elapsed_ms:.0f}ms")
    except AssertionError as e:
        write_result("TC01", "GET /users/1", "FAIL", response.status_code, elapsed_ms, str(e))
        raise


def test_post_user():
    """TC02: POST 테스트"""
    new_user = {"name": "Yoshitsugu Tester", "email": "test@example.com"}
    response = requests.post(f"{BASE_URL}/users", json=new_user, timeout=5)
    elapsed_ms = response.elapsed.total_seconds() * 1000

    try:
        assert response.status_code == 201
        assert "id" in response.json()
        write_result("TC02", "POST /users", "PASS", response.status_code, elapsed_ms)
        print(f"\n✅ TC02 PASS | {elapsed_ms:.0f}ms")
    except AssertionError as e:
        write_result("TC02", "POST /users", "FAIL", response.status_code, elapsed_ms, str(e))
        raise


def test_put_user():
    """TC03: PUT 테스트"""
    updated_user = {"id": 1, "name": "Updated User", "email": "updated@example.com"}
    response = requests.put(f"{BASE_URL}/users/1", json=updated_user, timeout=5)
    elapsed_ms = response.elapsed.total_seconds() * 1000

    try:
        assert response.status_code == 200
        write_result("TC03", "PUT /users/1", "PASS", response.status_code, elapsed_ms)
        print(f"\n✅ TC03 PASS | {elapsed_ms:.0f}ms")
    except AssertionError as e:
        write_result("TC03", "PUT /users/1", "FAIL", response.status_code, elapsed_ms, str(e))
        raise


def test_delete_user():
    """TC04: DELETE 테스트 + 삭제 후 GET 확인"""
    # Step1: 삭제
    response = requests.delete(f"{BASE_URL}/users/1", timeout=5)
    elapsed_ms = response.elapsed.total_seconds() * 1000

    try:
        assert response.status_code == 200
        write_result("TC04", "DELETE /users/1", "PASS", response.status_code, elapsed_ms)
        print(f"\n✅ TC04 PASS | {elapsed_ms:.0f}ms")
    except AssertionError as e:
        write_result("TC04", "DELETE /users/1", "FAIL", response.status_code, elapsed_ms, str(e))
        raise

    # Step2: 삭제 후 GET 확인
    # ※JSONPlaceholder는 목 API이므로 실제로는 삭제되지 않아 200이 반환되는 경우가 있다
    # 실제 API에서는 404가 반환되는 것을 기대하여 테스트를 작성한다
    response2 = requests.get(f"{BASE_URL}/users/1", timeout=5)
    assert response2.status_code in [200, 404], \
        f"삭제 후 GET은 404가 기댓값(목이므로 200의 경우 있음): {response2.status_code}"
    print(f"\n✅ TC04 삭제 후 GET 확인 | status: {response2.status_code}")

실행 커맨드

pytest test_report_api.py -v -s

생성되는 파일

project/
├── pytest.ini
├── test_report_api.py
├── report.html        ← pytest-html이 자동 생성
└── test_results.csv   ← Python 코드가 생성

CSV 출력 예시

실행 일시,테스트 ID,테스트 명,결과,상태 코드,응답 시간,비고
2026-04-06 10:23:01,TC01,GET /users/1,PASS,200,342ms,
2026-04-06 10:23:02,TC02,POST /users,PASS,201,289ms,
2026-04-06 10:23:03,TC03,PUT /users/1,PASS,200,310ms,
2026-04-06 10:23:04,TC04,DELETE /users/1,PASS,200,298ms,

06. 자주 겪는 문제 & 해결법

구현 중 실제로 겪었던 문제들을 정리했습니다. 같은 곳에서 막히는 분들께 도움이 되면 좋겠습니다.


① CSV를 열면 글자가 깨진다

Excel에서 CSV를 열었을 때 한국어가 깨져버렸습니다. 원인은 인코딩 설정이었습니다.

# ❌ utf-8은 Excel에서 열었을 때 깨지는 경우가 있다
with open(CSV_FILE, mode="w", encoding="utf-8") as f:
    ...

# ✅ utf-8-sig를 사용하면 Excel에서도 올바르게 표시된다
with open(CSV_FILE, mode="w", encoding="utf-8-sig") as f:
    ...

💡 포인트:utf-8-sig 는 BOM(Byte Order Mark)이 붙은 UTF-8입니다. Excel에서 CSV를 직접 열 경우는 utf-8-sig를 사용하면 글자 깨짐을 방지할 수 있습니다.


② 테스트를 실행할 때마다 CSV 헤더가 중복된다

테스트를 실행할 때마다 헤더 행이 추가되어버렸습니다.

# ❌ 매번 헤더를 써버린다
with open(CSV_FILE, mode="w") as f:
    writer.writerow(["테스트 ID", "결과", ...])  # 매번 덮어씀

# ✅ 파일이 존재하는지 체크하고 나서 쓴다
if not os.path.exists(CSV_FILE):
    with open(CSV_FILE, mode="w", newline="", encoding="utf-8-sig") as f:
        writer = csv.writer(f)
        writer.writerow(["실행 일시", "테스트 ID", "테스트 명", "결과", ...])

💡 포인트:os.path.exists() 로 파일 존재 확인을 하고 나서 쓰면 헤더 중복을 방지할 수 있습니다.


③ FAIL일 때 CSV가 기록되지 않는다

assert가 실패했을 때 예외가 던져져 CSV 쓰기 코드에 도달할 수 없었습니다.

# ❌ assert가 실패하면 write_result()가 호출되지 않는다
assert response.status_code == 200
write_result("TC01", "GET", "PASS", ...)  # 여기에 도달하지 못함

# ✅ try/except로 실패 시에도 CSV에 쓴다
try:
    assert response.status_code == 200
    write_result("TC01", "GET /users/1", "PASS", response.status_code, elapsed_ms)
except AssertionError as e:
    write_result("TC01", "GET /users/1", "FAIL", response.status_code, elapsed_ms, str(e))
    raise  # ← raise를 잊지 않도록!pytest에 실패를 전달

⚠️ 주의:except 안에서 raise 를 잊으면 테스트가 실패하고 있는데 pytest가 성공으로 판단해버립니다. 반드시 raise를 추가하세요.


④ report.html이 매번 덮어씌워져 과거 결과가 사라진다

pytest-html의 리포트는 매번 같은 report.html 에 덮어씌워집니다. 과거 리포트를 남기고 싶은 경우는 일시를 파일명에 넣습니다.

# pytest.ini에서는 변수를 사용할 수 없으므로 conftest.py를 사용하는 방법이 일반적
# conftest.py에 추가
import pytest
import datetime

def pytest_configure(config):
    now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    config.option.htmlpath = f"reports/report_{now}.html"

💡 포인트:conftest.py 를 사용하면 일시 포함 리포트 파일명을 동적으로 생성할 수 있습니다. 과거의 전체 테스트 결과를 폴더에 축적할 수 있습니다.


⑤ CSV의 응답 시간이 매번 들쑥날쑥하다

같은 테스트를 여러 번 실행하면 응답 시간이 매번 달라 비교가 어려웠습니다.

# ✅ 여러 번 실행하여 평균을 취하는 방법
import statistics

times = []
for _ in range(3):
    response = requests.get(f"{BASE_URL}/users/1", timeout=5)
    times.append(response.elapsed.total_seconds() * 1000)

avg_ms = statistics.mean(times)
write_result("TC01", "GET /users/1", "PASS", response.status_code, avg_ms, "3회 평균")

💡 포인트:성능 테스트에서는 여러 번 실행하여 평균값을 취하는 것으로 더 안정적인 계측값이 얻어집니다.


07. 자주 묻는 질문(FAQ)

Q. pytest-html의 리포트는 어디에 저장되나요?
A. 기본적으로 pytest를 실행한 디렉토리에 report.html 로 저장됩니다. pytest.ini--html=reports/report.html 처럼 경로를 지정하여 저장 위치를 변경할 수 있습니다.

Q. CSV와 HTML 리포트 어느 것을 사용하면 좋나요?
A. 용도에 따라 구분하세요. 사람에게 보여주거나 공유할 목적이라면 HTML 리포트, 데이터로 축적・분석한다면 CSV가 적합합니다. 실무에서는 둘 다 출력하는 경우가 많습니다.

Q. GitHub에 리포트를 업로드하는 방법은?
A. report.html 을 리포지토리에 포함하여 push하기만 하면 됩니다. GitHub의 경우 HTML을 그대로 표시할 수 없으므로 GitHub Pages를 사용하거나 리포트의 스크린샷을 README에 붙이는 것이 추천입니다.

Q. 테스트 결과를 Slack이나 메일로 전송할 수 있나요?
A. 예. Python의 smtplib(메일)이나 Slack API를 사용하면 테스트 완료 후 결과를 자동 전송할 수 있습니다. CI/CD와 조합하면 배포할 때마다 자동으로 테스트 결과가 통지되는 구조를 만들 수 있습니다.

Q. allure 리포트와 pytest-html은 어떻게 다른가요?
A. pytest-html은 심플하고 도입이 쉽지만 외관은 기본적입니다. allure는 풍부한 UI로 그래프나 트렌드 분석도 가능하여 더 본격적인 리포트를 만들 수 있습니다. 먼저 pytest-html로 시작하고 필요해지면 allure로 이행하는 것이 추천입니다.


08. 정리

Python API 테스트에 리포트 기능을 추가하는 경우, pytest-html과 csv 모듈을 조합하면 실무 수준의 에비던스를 자동 생성할 수 있습니다. 테스트 결과의 시각화는 품질 보증의 중요한 요소입니다.

이 글에서는 pytest와 requests를 사용한 API 테스트의 CSV 출력과 HTML 리포트 생성 방법을 해설했습니다.

기능구현 방법
HTML 리포트 자동 생성pytest.ini에 --html=report.html --self-contained-html 추가
CSV 출력Python csv 모듈 + try/except로 PASS/FAIL 모두 대응
헤더 중복 방지os.path.exists() 로 파일 존재 확인
글자 깨짐 방지인코딩에 utf-8-sig 사용
일시 포함 리포트conftest.py 로 동적 파일명 생성
fixture로 CSV 초기화@pytest.fixture(scope="session", autouse=True)
정확한 응답 시간response.elapsed.total_seconds() * 1000 사용
타임아웃모든 요청에 timeout=5 추가

테스트 결과를 HTML과 CSV로 출력할 수 있게 되면 「테스트를 작성할 수 있다」뿐만 아니라 「테스트 결과를 관리・보고할 수 있다」는 엔지니어로서의 어필이 됩니다💪

제목과 URL을 복사했습니다