フロントエンド開発やAPI連携のテストにおいて、バックエンドAPIがまだ準備できていない場合や、特定のレスポンスをシミュレートしたい場合に、モックサーバーが非常に役立ちます。ここでは、Pythonの標準ライブラリ wsgiref を用いて、JSONデータを返す簡易的なモックサーバーを構築する方法を紹介します。単に動くコードを貼るだけでなく、
- WSGI というインターフェースが具体的に何を規定しているか
- ステータスコード・CORSプリフライト・同時実行性というモックサーバー特有のハマりどころ
unittest.mockやresponses/respxといった他のモック手法との違い
を、実際にサーバーを起動して本物のHTTPリクエストを送った結果とともに検証します。
WSGI (Web Server Gateway Interface) とは何か
wsgiref.simple_server は、PythonのWebアプリケーションとWebサーバー間の標準インターフェースであるWSGI(
PEP 3333
)のリファレンス実装です。WSGIは「フレームワーク」ではなく、WebサーバーとPythonアプリケーションの間の呼び出し規約にすぎません。規約は驚くほど小さく、次の2つだけで成り立っています。
- アプリケーションは
application(environ, start_response)という呼び出し可能オブジェクトである - サーバーはリクエストごとにこれを呼び出し、返り値としてバイト列のイテラブルを受け取る
environ 辞書 — リクエストの実体
environ はCGIの環境変数を踏襲した辞書で、リクエストに関するあらゆる情報がここに詰め込まれます。主なキーは以下の通りです。
| キー | 内容 |
|---|---|
REQUEST_METHOD | GET / POST / OPTIONS などのHTTPメソッド |
PATH_INFO | リクエストパス(例: /api/1) |
QUERY_STRING | ? 以降のクエリ文字列 |
CONTENT_TYPE | リクエストボディのMIMEタイプ |
CONTENT_LENGTH | リクエストボディの長さ |
SERVER_NAME / SERVER_PORT | サーバーのホスト名・ポート |
wsgi.input | リクエストボディを読み取るファイルライクオブジェクト |
wsgi.errors | エラーログ出力先のファイルライクオブジェクト |
wsgi.url_scheme | http または 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-httpx | httpx / httpcore のトランスポート層をパッチ | なし(sync/async両対応) |
wsgiref.simple_server(本記事) / pytest-httpserver | 実際にTCPソケットをlistenするHTTPサーバーを起動 | あり(本物のHTTPリクエスト/レスポンスがネットワークスタックを通過する) |
unittest.mock や responses/respx は「Pythonのクライアントコードが呼び出す関数」を差し替えるアプローチなので高速でテストの独立性も高いですが、テスト対象がPython製のクライアントであることが前提です。一方、wsgiref ベースのモックサーバーは本物のTCP接続・HTTPパースを経由するため、ブラウザの fetch、curl、他言語の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()
これを、ThreadPoolExecutor で10個の同時リクエストを /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 秒 |
スレッド化(ThreadingMixIn) | 0.211 秒 | 0.209 秒 | 0.210 秒 |

シングルスレッド版では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.mockやresponses/respxのようなインプロセスのモックでは再現できない、ブラウザのCORSプリフライトや非Pythonクライアントからの検証が可能wsgiref.simple_serverはデフォルトでシングルスレッドであり、同時リクエストは直列化される。実測では10並行リクエストの合計処理時間がシングルスレッドで2.1秒、ThreadingMixInを使ったスレッド化版で0.21秒と、約10倍の差が出た- Pythonクライアントコードのユニットテストが目的なら
responses/respxのようなインプロセスモックの方がシンプルで高速。実HTTPサーバーが必要な場面ではpytest-httpserverが現在のデファクトに近い選択肢