PythonでJSONを返す簡易モックサーバーの構築

Pythonの標準ライブラリwsgirefを使ってJSONを返す簡易モックサーバーを構築する方法を、WSGIの仕組み・ステータスコード/CORSプリフライト/同時実行性の実測、unittest.mockやresponsesとの違いを含めて解説します。

フロントエンド開発やAPI連携のテストにおいて、バックエンドAPIがまだ準備できていない場合や、特定のレスポンスをシミュレートしたい場合に、モックサーバーが非常に役立ちます。ここでは、Pythonの標準ライブラリ wsgiref を用いて、JSONデータを返す簡易的なモックサーバーを構築する方法を紹介します。単に動くコードを貼るだけでなく、

  • WSGI というインターフェースが具体的に何を規定しているか
  • ステータスコード・CORSプリフライト・同時実行性というモックサーバー特有のハマりどころ
  • unittest.mockresponses / respx といった他のモック手法との違い

を、実際にサーバーを起動して本物のHTTPリクエストを送った結果とともに検証します。

WSGI (Web Server Gateway Interface) とは何か

wsgiref.simple_server は、PythonのWebアプリケーションとWebサーバー間の標準インターフェースであるWSGI( PEP 3333 )のリファレンス実装です。WSGIは「フレームワーク」ではなく、WebサーバーとPythonアプリケーションの間の呼び出し規約にすぎません。規約は驚くほど小さく、次の2つだけで成り立っています。

  1. アプリケーションは application(environ, start_response) という呼び出し可能オブジェクトである
  2. サーバーはリクエストごとにこれを呼び出し、返り値としてバイト列のイテラブルを受け取る

environ 辞書 — リクエストの実体

environ はCGIの環境変数を踏襲した辞書で、リクエストに関するあらゆる情報がここに詰め込まれます。主なキーは以下の通りです。

キー内容
REQUEST_METHODGET / POST / OPTIONS などのHTTPメソッド
PATH_INFOリクエストパス(例: /api/1
QUERY_STRING? 以降のクエリ文字列
CONTENT_TYPEリクエストボディのMIMEタイプ
CONTENT_LENGTHリクエストボディの長さ
SERVER_NAME / SERVER_PORTサーバーのホスト名・ポート
wsgi.inputリクエストボディを読み取るファイルライクオブジェクト
wsgi.errorsエラーログ出力先のファイルライクオブジェクト
wsgi.url_schemehttp または https

つまり environ.get("PATH_INFO") は魔法ではなく、CGI由来のプレーンな辞書アクセスです。

start_response — レスポンスを開始する呼び出し可能オブジェクト

application はサーバーから渡された start_response必ず一度だけ呼び出す必要があります。シグネチャは次の通りです。

start_response(status: str, response_headers: list[tuple[str, str]], exc_info=None)
  • status: "200 OK" のような、ステータスコードと理由句を含む文字列
  • response_headers: (名前, 値) のタプルのリスト
  • exc_info: エラー発生時にサーバーへ例外情報を伝えるための省略可能な引数

start_response を呼んだ後、application はレスポンスボディをバイト列のイテラブルとして返します(str ではなく bytes である点に注意)。これが守られていれば、内部実装がどうであれWSGI準拠のサーバー(wsgiref に限らず Gunicorn や uWSGI も含む)から呼び出し可能です。

他のモック手法との違い: 何が「本物」なのか

Pythonでモックというと unittest.mock を思い浮かべる人も多いはずですが、この記事で扱う「WSGIベースのモックサーバー」はレイヤーが全く異なります。

手法何を差し替えるか実際のソケット通信
unittest.mock.patch任意のPythonオブジェクト・関数呼び出しなし(プロセス内のオブジェクト置換)
responses(getsentry製)requests.Session.send をパッチしてリクエストをインターセプトなし(urllib3/ソケットに到達する前に横取り)
respx / pytest-httpxhttpx / httpcore のトランスポート層をパッチなし(sync/async両対応)
wsgiref.simple_server(本記事) / pytest-httpserver実際にTCPソケットをlistenするHTTPサーバーを起動あり(本物のHTTPリクエスト/レスポンスがネットワークスタックを通過する)

unittest.mockresponses/respx は「Pythonのクライアントコードが呼び出す関数」を差し替えるアプローチなので高速でテストの独立性も高いですが、テスト対象がPython製のクライアントであることが前提です。一方、wsgiref ベースのモックサーバーは本物のTCP接続・HTTPパースを経由するため、ブラウザの fetchcurl、他言語のHTTPクライアント、あるいはPlaywrightなどのE2Eテストからも同じように叩けます。CORSプリフライトのようにブラウザの挙動そのものを検証したい場合は、この「本物のサーバーを立てる」アプローチでなければ再現できません。

モックサーバーの構築

これらを踏まえて、/api/1 /api/2 に加えて 404 / 500 / CORSプリフライト(OPTIONS) / 疑似的な低速エンドポイントまで扱えるようにしたモックサーバーが以下です。

from wsgiref.simple_server import make_server
import json
import time

PORT = 8081

# モックデータの定義
# PATH_INFO (リクエストパス) に応じて返すJSONデータを設定
MOCK_DATA_SETTINGS = [
    {
        "PATH": "/api/1",
        "VALUE": {"items": [{"item1": "test1"}, {"item2": "test2"}]},
    },
    {
        "PATH": "/api/2",
        "VALUE": {"items2": [{"itemA": "testA"}, {"itemB": "testB"}]},
    },
]

ALLOWED_METHODS = "GET, POST, OPTIONS"
ALLOWED_HEADERS = "Content-Type"


def cors_headers():
    return [
        ("Access-Control-Allow-Origin", "*"),
        ("Access-Control-Allow-Methods", ALLOWED_METHODS),
        ("Access-Control-Allow-Headers", ALLOWED_HEADERS),
        ("Access-Control-Max-Age", "86400"),
    ]


def application(environ, start_response):
    """
    WSGIアプリケーションのエントリポイント。
    リクエストに応じて適切なJSONデータ・エラー・CORSヘッダーを返す。
    """
    method = environ.get("REQUEST_METHOD", "GET")
    path = environ.get("PATH_INFO", "/")

    # --- CORSプリフライト ---
    # ブラウザは Content-Type: application/json 付きの POST/PUT/DELETE などを
    # "simple request" とみなさないため、実リクエストの前に OPTIONS で
    # プリフライトを送る。ここで Access-Control-Allow-* を返さないと
    # 実リクエストは送信されずブラウザ側でブロックされる。
    if method == "OPTIONS":
        start_response("204 No Content", cors_headers())
        return [b""]

    # --- エラー系エンドポイント(意図的に 500 を返す) ---
    if path.startswith("/api/error"):
        headers = [("Content-type", "text/plain; charset=utf-8")] + cors_headers()
        start_response("500 Internal Server Error", headers)
        return [b"500 Internal Server Error"]

    # --- 疑似的に遅いエンドポイント(同時実行性デモ用) ---
    if path.startswith("/api/slow"):
        time.sleep(0.2)
        headers = [
            ("Content-type", "application/json; charset=utf-8"),
        ] + cors_headers()
        start_response("200 OK", headers)
        return [json.dumps({"slept": 0.2}).encode("utf-8")]

    # --- 通常のモックデータ ---
    for setting in MOCK_DATA_SETTINGS:
        if path.startswith(setting["PATH"]):
            headers = [
                ("Content-type", "application/json; charset=utf-8"),
            ] + cors_headers()
            start_response("200 OK", headers)
            return [json.dumps(setting["VALUE"]).encode("utf-8")]

    # --- どのルールにもマッチしない場合は 404 ---
    headers = [("Content-type", "text/plain; charset=utf-8")] + cors_headers()
    start_response("404 Not Found", headers)
    return [b"404 Not Found"]


if __name__ == "__main__":
    httpd = make_server("", PORT, application)
    print(f"Serving on port {PORT}...")
    httpd.serve_forever()

コードの解説

  • make_server('', PORT, application): '' はすべての利用可能なインターフェースからの接続を受け入れることを意味します(0.0.0.0 と同等)。application はリクエストごとに呼び出されるWSGIアプリケーションです。
  • cors_headers(): Access-Control-Allow-* 系ヘッダーをまとめたヘルパー。200/404/500/OPTIONSのどのレスポンスにも付与することで、ブラウザからのクロスオリジンアクセスを一貫して許可しています。
  • OPTIONS 分岐: プリフライトリクエストにはボディなしの 204 No Content で応答するのが一般的です。ここで許可するメソッド・ヘッダーをブラウザに伝えます。
  • /api/error: 意図的に 500 Internal Server Error を返すエンドポイント。バックエンド障害時のフロントエンドの挙動(エラーハンドリング、リトライ、トースト表示など)をテストするために用意しました。
  • /api/slow: time.sleep(0.2) で処理に200ミリ秒かかるAPIを模しています。後述の同時実行性の検証で使います。
  • どのパスにもマッチしない場合は 404 Not Found を返します。

実際にリクエストを送って検証する

ここからは実際にこのサーバーをサブプロセスとして起動し、requests ライブラリで本物のHTTPリクエストを送って挙動を確認します(掲載している出力はすべて実行結果そのものです)。

ステータスコード: 200 / 404 / 500

import subprocess, sys, time, requests

proc = subprocess.Popen([sys.executable, "mock_server.py"], ...)
try:
    r = requests.get("http://localhost:8081/api/1")
    print(r.status_code, r.text)

    r = requests.get("http://localhost:8081/api/2")
    print(r.status_code, r.text)

    r = requests.get("http://localhost:8081/api/nonexistent")
    print(r.status_code, r.text)

    r = requests.get("http://localhost:8081/api/error")
    print(r.status_code, r.text)
finally:
    proc.terminate()  # デモ終了後は必ずプロセスを後始末する
    proc.wait(timeout=5)

実行結果:

=== GET /api/1 (200 OK) ===
status: 200
headers: Content-Type=application/json; charset=utf-8
body: {"items": [{"item1": "test1"}, {"item2": "test2"}]}

=== GET /api/2 (200 OK) ===
status: 200
body: {"items2": [{"itemA": "testA"}, {"itemB": "testB"}]}

=== GET /api/nonexistent (404 Not Found) ===
status: 404
body: 404 Not Found

=== GET /api/error (500 Internal Server Error) ===
status: 500
body: 500 Internal Server Error

設定した2つのパス以外はすべて404にフォールバックし、/api/error は明示的に500を返すことが実際のHTTPレスポンスとして確認できました。フロントエンドのエラーハンドリングを網羅的にテストするには、正常系のモックだけでなく、こうした異常系エンドポイントを用意しておくことが重要です。

CORSプリフライト(OPTIONS)の検証

ブラウザは、Content-Type: application/json を付けた POST のような「simple request」に該当しないリクエストを送る前に、同じURLに対して OPTIONS メソッドでプリフライトリクエストを送ります。サーバーがそこで適切な Access-Control-Allow-* ヘッダーを返さない限り、実際のリクエストはブラウザによってブロックされ、JavaScript側には CORS error としてしか見えません。これはモックサーバーを作る上で最もハマりやすいポイントの一つです。

r = requests.options(
    "http://localhost:8081/api/1",
    headers={
        "Origin": "https://example.com",
        "Access-Control-Request-Method": "POST",
        "Access-Control-Request-Headers": "Content-Type",
    },
)

実行結果:

=== OPTIONS /api/1 (CORS preflight) ===
status: 204
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
body length: 0

=== Actual request following preflight: GET /api/1 with Origin header ===
status: 200
Access-Control-Allow-Origin: *
body: {"items": [{"item1": "test1"}, {"item2": "test2"}]}

プリフライトが 204 かつ必要なヘッダー一式を返しているため、後続の実リクエストがブロックされずに 200 で応答されています。Access-Control-Max-Age: 86400 はブラウザがこのプリフライト結果を24時間キャッシュしてよいことを示すヘッダーで、これがないと同じオリジンからのリクエストのたびに毎回プリフライトが飛ぶことになります。

同時リクエストへの対応: シングルスレッド vs スレッド化

wsgiref.simple_server.make_server が返す WSGIServer は、socketserver.TCPServer を継承しただけのシングルスレッドサーバーです。つまり、あるリクエストの処理が終わるまで次のリクエストのソケット処理は始まりません。フロントエンドの開発中に単発でアクセスする分には問題になりませんが、複数のリクエストを並行して送るテスト(例えばブラウザが複数のAPIを同時に呼ぶ画面や、負荷テストツール)ではボトルネックになります。

socketserver.ThreadingMixIn を混ぜるだけで、リクエストごとにスレッドを起動するサーバーに変えられます。

from socketserver import ThreadingMixIn
from wsgiref.simple_server import WSGIServer, make_server
import time

PORT = 8082


def application(environ, start_response):
    time.sleep(0.2)  # 疑似的なI/O待ち
    start_response("200 OK", [("Content-type", "text/plain; charset=utf-8")])
    return [b"ok"]


class ThreadingWSGIServer(ThreadingMixIn, WSGIServer):
    daemon_threads = True  # プロセス終了時にリクエスト処理スレッドを道連れにする


def main(mode):
    if mode == "threaded":
        httpd = make_server("", PORT, application, server_class=ThreadingWSGIServer)
    else:
        httpd = make_server("", PORT, application)  # デフォルトはシングルスレッド
    httpd.serve_forever()

これを、ThreadPoolExecutor10個の同時リクエスト/api/slow(0.2秒スリープ)に送るベンチマークで比較しました。

from concurrent.futures import ThreadPoolExecutor
import time, requests

def run_one_request(_):
    t0 = time.perf_counter()
    requests.get("http://localhost:8082/api/slow")
    return time.perf_counter() - t0

t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as ex:
    latencies = list(ex.map(run_one_request, range(10)))
total = time.perf_counter() - t0

実測結果(実際にサーバーを起動してベンチマークした結果です):

モード合計処理時間(10リクエスト)平均レイテンシ最大レイテンシ
シングルスレッド(デフォルト)2.107 秒1.161 秒2.104 秒
スレッド化(ThreadingMixIn0.211 秒0.209 秒0.210 秒

wsgirefのシングルスレッドサーバーとスレッド化サーバーで10並行リクエストを送った際の合計処理時間とリクエストごとのレイテンシの比較

シングルスレッド版では10本のリクエストがサーバー内で完全に直列化されるため、合計処理時間はほぼ「0.2秒 × 10 = 2秒」に一致し、右のグラフでもレイテンシがリクエスト順に階段状に増加しています。スレッド化版ではすべてのリクエストがほぼ同時に処理されるため、合計処理時間は1回分のスリープ時間(約0.21秒)に収束し、レイテンシもほぼ一定です。

注意点として、daemon_threads = True を設定しないと Ctrl+C でサーバーを止めた際に処理中のスレッドがプロセスをブロックし続けることがあります。また、モックアプリケーションが辞書やリストなどの共有可変状態を書き換える場合は、スレッド化するとレースコンディションが発生しうるため、threading.Lock などで保護するか、そもそもモック用途では状態を持たせない設計にするのが安全です。なお wsgiref はあくまで開発・テスト用のリファレンス実装であり、本番トラフィックを捌く用途には Gunicorn や uWSGI のような実運用向けWSGIサーバーを使うべきという位置付けは変わりません。

最近のツールの動向

WSGIベースの自前モックサーバーは学習目的や「本物のHTTPサーバーが必要な場面」では今も有効ですが、2026年時点でPythonのHTTPクライアントコードをテストする目的であれば、より専用化されたツールが充実しています。

  • responses (getsentry製): requests を使うコードのテスト向け。requests.Session.send をパッチしてリクエストをインターセプトするため、実際のソケット通信は発生しません。デコレータ/コンテキストマネージャで簡潔に書けます。
  • respx / pytest-httpx: httpx を使うコードのテスト向け。トランスポート層をパッチする方式で、同期・非同期どちらのクライアントにも対応します。
  • pytest-httpserver (2026年7月時点最新版 1.1.5): Werkzeugベースの本物のHTTPサーバーをpytestのフィクスチャとして起動してくれるライブラリです。本記事の wsgiref アプローチと発想は同じ(実ソケット通信を伴う)ですが、サーバーのライフサイクル管理やルーティングDSLが最初から用意されているため、実運用ではこちらを使う方が生産性が高いでしょう。

使い分けの目安としては、テスト対象がPython製のHTTPクライアントに閉じているなら responses / respx のようなインプロセスの差し替えが高速で扱いやすく、ブラウザのCORS挙動やE2Eテスト、あるいは他言語のクライアントを検証したい場合は、実際にソケットをlistenする wsgiref(または pytest-httpserver)ベースのアプローチが必要になります。

まとめ

  • WSGIは environ 辞書と start_response 呼び出し可能オブジェクトという2つの要素からなる、驚くほど小さな呼び出し規約である
  • wsgiref ベースのモックサーバーは本物のTCPソケットを開くため、unittest.mockresponses/respx のようなインプロセスのモックでは再現できない、ブラウザのCORSプリフライトや非Pythonクライアントからの検証が可能
  • wsgiref.simple_server はデフォルトでシングルスレッドであり、同時リクエストは直列化される。実測では10並行リクエストの合計処理時間がシングルスレッドで2.1秒、ThreadingMixIn を使ったスレッド化版で0.21秒と、約10倍の差が出た
  • Pythonクライアントコードのユニットテストが目的なら responses / respx のようなインプロセスモックの方がシンプルで高速。実HTTPサーバーが必要な場面では pytest-httpserver が現在のデファクトに近い選択肢

参考