WebSocket Testing¶
Property-based testing for WebSocket endpoints with automatic message generation and protocol validation.
pytest-routes extends its property-based testing approach to WebSocket endpoints, automatically discovering WebSocket routes and generating randomized message sequences to validate your real-time API behavior.
Quick Start¶
Enable WebSocket testing alongside regular route testing:
pytest --routes --routes-app myapp:app --routes-websocket
This automatically:
Discovers WebSocket routes from your application
Generates message sequences using Hypothesis
Tests connection establishment, message exchange, and graceful shutdown
Reports failures with minimal reproducing examples
How It Works¶
Route Discovery¶
pytest-routes detects WebSocket routes from your ASGI application:
Litestar:
from litestar import Litestar, websocket
@websocket("/ws/chat")
async def chat_handler(socket) -> None:
await socket.accept()
while True:
data = await socket.receive_text()
await socket.send_text(f"Echo: {data}")
app = Litestar([chat_handler])
FastAPI/Starlette:
from fastapi import FastAPI, WebSocket
app = FastAPI()
@app.websocket("/ws/chat")
async def chat_handler(websocket: WebSocket) -> None:
await websocket.accept()
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Echo: {data}")
Message Generation¶
For each WebSocket route, pytest-routes generates message sequences:
# Generated message sequence example
[
("text", "Hello, World!"),
("json", {"action": "subscribe", "channel": "news"}),
("text", "Another message"),
("bytes", b"\x00\x01\x02"),
]
Message types include:
Text messages: Random strings and structured text
JSON messages: Randomly generated JSON objects
Binary messages: Random byte sequences
Test Execution¶
Each test:
Establishes a WebSocket connection
Sends the generated message sequence
Validates that the server handles all messages without crashing
Verifies graceful connection shutdown
CLI Options¶
Option |
Default |
Description |
|---|---|---|
|
|
Enable WebSocket route testing |
|
|
Maximum messages per test sequence |
|
|
Connection timeout in seconds |
|
|
Message receive timeout in seconds |
|
|
Comma-separated patterns to include |
|
|
Comma-separated patterns to exclude |
Example Commands¶
# Basic WebSocket testing
pytest --routes --routes-app myapp:app --routes-websocket
# Quick smoke test (fewer messages)
pytest --routes --routes-app myapp:app --routes-websocket \
--routes-ws-max-messages 3
# Test specific WebSocket routes
pytest --routes --routes-app myapp:app --routes-websocket \
--routes-ws-include "/ws/chat,/ws/notifications"
# Exclude internal WebSocket routes
pytest --routes --routes-app myapp:app --routes-websocket \
--routes-ws-exclude "/ws/internal/*,/ws/admin/*"
# Extended timeout for slow handlers
pytest --routes --routes-app myapp:app --routes-websocket \
--routes-ws-timeout 60.0 --routes-ws-message-timeout 30.0
# Combined with regular route testing
pytest --routes --routes-app myapp:app --routes-websocket \
--routes-max-examples 50
Configuration¶
Configure WebSocket testing in pyproject.toml:
[tool.pytest-routes.websocket]
enabled = true
max_messages = 10
connection_timeout = 30.0
message_timeout = 10.0
max_message_size = 65536
test_close_codes = [1000, 1001]
validate_subprotocols = true
include = ["/ws/*"]
exclude = ["/ws/internal/*"]
Configuration Options¶
Option |
Type |
Description |
|---|---|---|
|
|
Enable WebSocket testing |
|
|
Maximum messages per test sequence |
|
|
Connection establishment timeout (seconds) |
|
|
Message receive timeout (seconds) |
|
|
Maximum generated message size (bytes) |
|
|
Close codes to test for graceful shutdown |
|
|
Validate subprotocol negotiation |
|
|
Glob patterns to include routes |
|
|
Glob patterns to exclude routes |
Message Strategies¶
pytest-routes provides built-in message strategies for different protocols:
Text Messages¶
Random strings with configurable length:
# Generated examples
"Hello"
"A longer message with special chars: @#$%"
"" # Empty string (edge case)
JSON Messages¶
Randomly generated JSON structures:
# Generated examples
{"key": "value"}
{"nested": {"deep": {"structure": 42}}}
{"array": [1, 2, 3], "boolean": true, "null": null}
Binary Messages¶
Random byte sequences:
# Generated examples
b"\x00\x01\x02"
b"\xff\xfe\xfd"
b"" # Empty bytes (edge case)
GraphQL Subscriptions¶
For GraphQL WebSocket endpoints:
# Generated examples
{"type": "connection_init", "payload": {}}
{"type": "subscribe", "id": "1", "payload": {"query": "subscription { ..."}}
Custom Message Strategies¶
Register custom strategies for your application’s protocol:
# conftest.py
from hypothesis import strategies as st
from pytest_routes.websocket import register_message_strategy
# Custom chat protocol
chat_message = st.fixed_dictionaries({
"type": st.sampled_from(["message", "typing", "presence"]),
"content": st.text(min_size=1, max_size=500),
"timestamp": st.integers(min_value=0),
})
# Register for specific route pattern
register_message_strategy("/ws/chat", "json", chat_message)
# Custom binary protocol
binary_command = st.binary(min_size=4, max_size=4) # 4-byte command
register_message_strategy("/ws/binary", "bytes", binary_command)
Framework Support¶
Litestar¶
Litestar WebSocket routes are auto-detected with full type information:
from litestar import Litestar, websocket
from litestar.channels import ChannelsPlugin
from litestar.channels.backends.memory import MemoryChannelsBackend
@websocket("/ws/notifications")
async def notifications_handler(socket) -> None:
await socket.accept()
# Handle messages...
@websocket("/ws/chat/{room_id:str}")
async def chat_room_handler(socket, room_id: str) -> None:
await socket.accept()
# Handle messages for specific room...
app = Litestar(
route_handlers=[notifications_handler, chat_room_handler],
plugins=[ChannelsPlugin(backend=MemoryChannelsBackend())],
)
Note
Litestar WebSocket handlers auto-accept connections, which pytest-routes handles transparently.
FastAPI¶
FastAPI WebSocket routes require manual accept:
from fastapi import FastAPI, WebSocket
app = FastAPI()
@app.websocket("/ws/chat")
async def chat_handler(websocket: WebSocket) -> None:
await websocket.accept() # Required in FastAPI
try:
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Echo: {data}")
except Exception:
pass
@app.websocket("/ws/binary")
async def binary_handler(websocket: WebSocket) -> None:
await websocket.accept()
data = await websocket.receive_bytes()
await websocket.send_bytes(data)
Starlette¶
Starlette WebSocket routes work similarly to FastAPI:
from starlette.applications import Starlette
from starlette.routing import WebSocketRoute
from starlette.websockets import WebSocket
async def chat_handler(websocket: WebSocket) -> None:
await websocket.accept()
while True:
data = await websocket.receive_text()
await websocket.send_text(f"Echo: {data}")
app = Starlette(routes=[
WebSocketRoute("/ws/chat", chat_handler),
])
Chat Server Example¶
A complete example testing a chat server:
Application:
# chat_app.py
from litestar import Litestar, websocket
connected_clients: list = []
@websocket("/ws/chat")
async def chat_handler(socket) -> None:
await socket.accept()
connected_clients.append(socket)
try:
while True:
message = await socket.receive_text()
# Broadcast to all clients
for client in connected_clients:
if client is not socket:
await client.send_text(message)
finally:
connected_clients.remove(socket)
app = Litestar([chat_handler])
Test configuration:
# pyproject.toml
[tool.pytest-routes]
app = "chat_app:app"
[tool.pytest-routes.websocket]
enabled = true
max_messages = 20
connection_timeout = 10.0
message_timeout = 5.0
Running tests:
pytest --routes --routes-websocket -v
Example output:
tests/test_routes.py::test_websocket[/ws/chat] PASSED
pytest-routes: WebSocket Test Summary
=====================================
Route: /ws/chat
Sequences tested: 100
Messages sent: 1,247
Connections established: 100
Failures: 0
=====================================
Failure Reporting¶
When a WebSocket test fails, pytest-routes provides detailed failure information:
============================================================
WEBSOCKET TEST FAILURE: /ws/chat
============================================================
Error Type: message_handler_error
Message: Server closed connection unexpectedly
Failed at message index: 5
Sent (json):
{"type": "subscribe", "channel": "invalid_\x00_channel"}
Expected:
Connection to remain open
Actual:
Connection closed with code 1011
Connection State: closed
Close Code: 1011
Additional Context:
total_messages_sent: 5
last_response: None
elapsed_time_ms: 127.5
============================================================
Reproducing Failures¶
Use the seed for reproducibility:
# Original failure
pytest --routes --routes-app myapp:app --routes-websocket
# Output: WebSocket test failed (seed: 54321)
# Reproduce
pytest --routes --routes-app myapp:app --routes-websocket \
--routes-seed 54321 -v
Testing Patterns¶
Echo Server Testing¶
For simple echo servers:
@websocket("/ws/echo")
async def echo(socket) -> None:
await socket.accept()
while True:
msg = await socket.receive_text()
await socket.send_text(msg)
Test validates:
Connection establishment
Message round-trip
Various message contents (including edge cases)
Graceful shutdown
Pub/Sub Testing¶
For publish-subscribe patterns:
@websocket("/ws/subscribe/{channel:str}")
async def subscribe(socket, channel: str) -> None:
await socket.accept()
# Subscribe to channel, receive broadcasts
Test validates:
Path parameter handling
Subscription lifecycle
Message handling per channel
Authentication Testing¶
For authenticated WebSocket endpoints:
@websocket("/ws/private")
async def private_handler(socket) -> None:
# Check auth token in query params or first message
token = socket.query_params.get("token")
if not validate_token(token):
await socket.close(code=4001)
return
await socket.accept()
# Handle authenticated messages
Test with authentication:
pytest --routes --routes-app myapp:app --routes-websocket \
--routes-auth "bearer:$WS_AUTH_TOKEN"
Best Practices¶
Set appropriate timeouts: WebSocket tests can hang if timeouts are too long
--routes-ws-timeout 10.0 --routes-ws-message-timeout 5.0Limit message count for development: Start small, increase for CI
# Development: quick feedback --routes-ws-max-messages 5 # CI: thorough testing --routes-ws-max-messages 20
Exclude internal routes: Focus on public-facing endpoints
--routes-ws-exclude "/ws/internal/*,/ws/debug/*"Combine with HTTP testing: Test both in one run
pytest --routes --routes-websocket --routes-app myapp:appUse custom strategies: Match your application’s protocol
register_message_strategy("/ws/api", "json", your_api_message_strategy)
Troubleshooting¶
Connection Refused¶
If connections are refused:
Verify the route is a WebSocket route (not HTTP)
Check the route pattern matches correctly
Ensure the handler calls
accept()(FastAPI/Starlette)
Timeout Errors¶
If tests timeout:
Increase
--routes-ws-timeoutfor connection phaseIncrease
--routes-ws-message-timeoutfor message handlingCheck if your handler has infinite loops without proper exception handling
Unexpected Closures¶
If connections close unexpectedly:
Check handler exception handling
Verify message format matches handler expectations
Enable verbose mode to see message sequences:
pytest --routes --routes-app myapp:app --routes-websocket --routes-verbose
See Also¶
CLI Options Reference - Complete CLI documentation
Configuration - Full configuration reference
Stateful Testing - API workflow testing