pytest 用に非同期サーバーを別スレッドで建てる
Context
httpx のような HTTP クライアントは同期 / 非同期 IO に対応して便利です。
httpx を使って何かの特定の Web API SDK を開発しているとき、pytest によるテストでモックサーバーにリクエストをしたいのですが、もちろんテストでも同期 / 非同期 IO の両方で同じ仕様のモックサーバーへテストリクエストしたいです。
また個人的な要件で HTTP リクエストだけでなく WebSocket API も利用します。 WebSocket に関してはラッパーライブラリである httpx-ws で実現できるのですが、これに関しても同じく同期 / 非同期でテストしたいです。
以上の背景から、このスクラップでは以下の要件を満たせるようなテスト用モックサーバーを建てる方法を探っていきます。
- pytest の Fixture でシンプルに実現できる
- 同期 / 非同期
- HTTP / WebSocket
- サーバーのパッケージが巨大な依存関係ではない
- ASGI
Async server with threading
pytest の Fixture にて非同期サーバーを建ててクライアントからリクエストしようとすると、まず直面する問題点があります。
- 非同期 Fixture でサーバーを建てると、テストの同期クライアントからリクエストするとイベントループが停止してサーバーがレスポンスを返せない
非同期サーバーでなく、同期サーバーを建てるとしても別の問題に直面します。
- Fixture にて別スレッドでサーバーを実行する必要あり、別スレッドで立てたサーバーのシャットダウン処理がそこそこ難しい (シャットダウンができないと pytest が終了しない)
- そもそも同期サーバーの多くは非同期が前提の WebSocket に対応していない
これを解決するには「別スレッドで非同期サーバーを建てる」という方法がよさそうです。 別スレッドでイベントループが動くので、テスト関数において同期リクエストを実行してもレスポンスが返されます。 また非同期サーバーであれば多くの場合 WebSocket を利用できます。
別スレッドで非同期サーバーを建てるには、anyio の blocking portal 機能を使うことで正しい手続きができます。 例えば Server()
が特定の非同期サーバークラスだとして、以下のスニペットで実現できます。
from contextlib import asynccontextmanager
from anyio import from_thread
import httpx
import pytest
@asynccontextmanager
async def serve_test_server(...):
server = Server()
await server.start()
yield server
await server.shutdown()
@pytest.fixture(scope="session")
def my_test_server():
with (
from_thread.start_blocking_portal() as portal,
portal.wrap_async_context_manager(serve_test_server(...)) as server,
):
yield server
def test_client(my_test_server):
with httpx.Client() as client:
r = client.get(my_test_server.url)
assert r.status_code == 200
@pytest.mark.anyio
async def test_client(my_test_server):
async with httpx.AsyncClient() as client:
r = await client.get(my_test_server.url)
assert r.status_code == 200
こちらのスニペットにより、非同期サーバーがあれば同期 / 非同期クライアントによるリクエストをテストを実現できます。 テストが終わったあと、Fixture のシャットダウンも問題ありません。 以降はこれを元に、要件を満たす非同期サーバーを探していきます。
aiohttp
✅ pytest Fixture
✅ threading
✅ HTTP / WebSocket
❌ ASGI
import aiohttp
import pytest
from aiohttp import web
from aiohttp.test_utils import TestServer
from anyio import from_thread
if TYPE_CHECKING:
from collections.abc import Generator
@pytest.fixture(scope="session")
def aiohttp_server() -> Generator[TestServer, None, None]:
async def http_handler(request: web.Request) -> web.Response:
return web.json_response({"Hello": "World"})
async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
await ws.send_str(f"Message text was: {msg.data}")
return ws
app = web.Application()
app.add_routes(
[
web.route("*", "/", http_handler),
web.route("GET", "/ws", websocket_handler),
]
)
with from_thread.start_blocking_portal(
backend="asyncio"
) as portal, portal.wrap_async_context_manager(TestServer(app)) as server:
yield server
@pytest.fixture(scope="session")
def http_server(aiohttp_server: TestServer) -> str:
return str(aiohttp_server.make_url("/"))
@pytest.fixture(scope="session")
def websocket_server(aiohttp_server: TestServer) -> str:
return str(aiohttp_server.make_url("/ws"))
def test_websocket(websocket_server: str):
with httpx_ws.connect_ws(websocket_server) as ws:
ws.send_text("Hello, world!")
assert ws.receive_text() == "Message text was: Hello, world!"
@pytest.mark.anyio
async def test_async_websocket(websocket_server: str):
async with httpx_ws.aconnect_ws(websocket_server) as ws:
await ws.send_text("Hello, world!")
assert await ws.receive_text() == "Message text was: Hello, world!"
Public API として提供されている TestServer
クラスが大変使いやすいです。 pytest-aiohttp
の方を利用してしまうと、前述した同期リクエストの問題でレスポンスがブロックされてしまいますので、このスニペットが必要になります。
ただし ASGI ではないので、汎用性は少し落ちます。
Uvicorn
✅ pytest Fixture
✅ threading
✅ HTTP / WebSocket
✅ ASGI
from collections.abc import Generator
from contextlib import asynccontextmanager
from socket import socket
from typing import AsyncGenerator
import httpx_ws
import pytest
import uvicorn
from anyio import from_thread
from fastapi import FastAPI, WebSocket
from uvicorn._types import ASGIApplication
@asynccontextmanager
async def serve_test_server(app: ASGIApplication) -> AsyncGenerator[socket, None]:
server = uvicorn.Server(uvicorn.Config(app, port=0))
config = server.config
if not config.loaded:
config.load()
server.lifespan = config.lifespan_class(config)
sock = config.bind_socket()
await server.startup([sock])
if server.should_exit:
return
yield sock
await server.shutdown([sock])
@pytest.fixture(scope="session")
def uvicorn_server() -> Generator[socket, None, None]:
app = FastAPI()
@app.get("/")
def read_root():
return {"Hello": "World"}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Message text was: {data}")
with (
from_thread.start_blocking_portal() as portal,
portal.wrap_async_context_manager(serve_test_server(app)) as server,
):
yield server
@pytest.fixture(scope="session")
def http_server(uvicorn_server: socket) -> str:
host, port = uvicorn_server.getsockname()
return f"http://{host}:{port}"
@pytest.fixture(scope="session")
def websocket_server(uvicorn_server: socket) -> str:
host, port = uvicorn_server.getsockname()
return f"http://{host}:{port}/ws"
def test_websocket(websocket_server: str):
with httpx_ws.connect_ws(websocket_server) as ws:
ws.send_text("Hello, world!")
assert ws.receive_text() == "Message text was: Hello, world!"
@pytest.mark.anyio
async def test_async_websocket(websocket_server: str):
async with httpx_ws.aconnect_ws(websocket_server) as ws:
await ws.send_text("Hello, world!")
assert await ws.receive_text() == "Message text was: Hello, world!"
ASGI 準拠の HTTP サーバーなので、FastAPI でも Quart でもなんでも利用できます。
だたし Publish API として説明されていない uvicorn.Server
クラスの startup()
などを利用している点に注意が必要です。
pytest-httpbin
✅ pytest Fixture
✅ threading
✅ HTTP
❌ WebSocket
❌ ASGI
こちらは WebSocket の要件を満たしませんが、HTTP に限っては非常に簡単に構築できるサーバーです。 公開サーバーである https://httpbin.org/ と同等のサーバーをローカルで実行します。
フィクスチャとして httpbin
を利用するだけです。 httpcore のテストケースで利用されていました。