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に埋め込まれてファイル1つで完結します。GitHubへのアップロードやクライアントへの共有がそのままできます。


03. PythonでAPIテスト結果をCSVに書き出す方法

PythonのcsvモジュールはPython標準ライブラリなので追加インストール不要です。このモジュールを使えば、テスト結果を手軽に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):
        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で日時付きのファイル名にする
# ただし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):
    start = time.time()
    response = requests.get(f"{BASE_URL}/users/1")
    times.append((time.time() - start) * 1000)

avg_ms = statistics.mean(times)
write_result("TC01", "GET /users/1", "PASS", response.status_code, avg_ms, f"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 で動的ファイル名を生成

テスト結果をHTMLとCSVで出力できるようになれば、「テストを書ける」だけでなく「テスト結果を管理・報告できる」エンジニアとしてのアピールになります💪

タイトルとURLをコピーしました