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에 쓰는 방법
- 테스트 결과에 커스텀 정보(일시・상태・응답 시간)를 추가하는 방법
- 포트폴리오・클라이언트 보고에 사용할 수 있는 에비던스 만드는 방법
00. API 테스트 리포트・CSV 출력이 중요한 이유
테스트를 실행하는 것만으로는 충분하지 않습니다. 실무에서는 결과를 기록・공유하는 구조를 만드는 것이 중요합니다.
| 출력 형식 | 용도 | 장점 |
|---|---|---|
| HTML 리포트 | 테스트 결과 시각화 | 브라우저에서 보기 편함・GitHub에서 공유하기 쉬움 |
| CSV 파일 | 데이터 기록・분석 | Excel에서 열 수 있음・과거 결과와 비교 가능 |
| 터미널 출력 | 즉시 확인 | 개발 중 디버깅에 편리 |
💡 포인트:포트폴리오로 공개한다면 HTML 리포트를 GitHub에 포함하는 것만으로 단번에 프로답게 보입니다. 클라이언트 보고에도 그대로 사용할 수 있습니다.
01. pytest + requests의 API 테스트 리포트 환경
아직 환경 구축이 안 된 분은 먼저 설치해주세요.
pip install requests pytest pytest-html| 라이브러리 | 역할 |
|---|---|
pytest | 테스트 실행・관리 |
pytest-html | HTML 리포트 자동 생성 |
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로 출력할 수 있게 되면 「테스트를 작성할 수 있다」뿐만 아니라 「테스트 결과를 관리・보고할 수 있다」는 엔지니어로서의 어필이 됩니다💪

