mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
optimize test suite (#1893)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1f2d3f2165
commit
04831b75c2
16 changed files with 88 additions and 40 deletions
4
.github/workflows/run-tests.yml
vendored
4
.github/workflows/run-tests.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests (excluding integration and client_process)
|
||||
run: uv run pytest --inline-snapshot=disable -v tests -m "not integration and not client_process"
|
||||
run: uv run pytest --inline-snapshot=disable -v tests -m "not integration and not client_process" --numprocesses auto --maxprocesses 4 --dist worksteal
|
||||
|
||||
- name: Run client process tests separately
|
||||
run: uv run pytest --inline-snapshot=disable -v tests -m "client_process" -x
|
||||
|
|
@ -74,7 +74,7 @@ jobs:
|
|||
|
||||
- name: Run integration tests
|
||||
# use longer per-test timeout than the default 3s
|
||||
run: uv run pytest -v tests -m "integration" --timeout=15
|
||||
run: uv run pytest -v tests -m "integration" --timeout=15 --numprocesses auto --maxprocesses 2 --dist worksteal
|
||||
env:
|
||||
FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }}
|
||||
FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ def run_server_in_process(
|
|||
proc.start()
|
||||
|
||||
# Wait for server to be running
|
||||
max_attempts = 10
|
||||
max_attempts = 30
|
||||
attempt = 0
|
||||
while attempt < max_attempts and proc.is_alive():
|
||||
try:
|
||||
|
|
@ -117,10 +117,12 @@ def run_server_in_process(
|
|||
s.connect((host, port))
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
if attempt < 3:
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
if attempt < 5:
|
||||
time.sleep(0.05)
|
||||
elif attempt < 15:
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
attempt += 1
|
||||
else:
|
||||
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ def run_server(host: str, port: int, **kwargs) -> None:
|
|||
fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def streamable_http_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="http") as url:
|
||||
yield f"{url}/mcp"
|
||||
|
|
|
|||
|
|
@ -55,19 +55,19 @@ def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
|
|||
app.run(host=host, port=port, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def shttp_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="http") as url:
|
||||
yield f"{url}/mcp"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def sse_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="sse") as url:
|
||||
yield f"{url}/sse"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def proxy_server(shttp_server: str) -> Generator[str, None, None]:
|
||||
with run_server_in_process(
|
||||
run_proxy_server,
|
||||
|
|
|
|||
|
|
@ -52,19 +52,19 @@ def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
|
|||
app.run(host=host, port=port, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def shttp_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="http") as url:
|
||||
yield f"{url}/mcp"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def sse_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="sse") as url:
|
||||
yield f"{url}/sse"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def proxy_server(shttp_server: str) -> Generator[str, None, None]:
|
||||
with run_server_in_process(
|
||||
run_proxy_server,
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ def run_server(host: str, port: int, **kwargs) -> None:
|
|||
fastmcp_server().run(host=host, port=port, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
@pytest.fixture(autouse=True)
|
||||
def sse_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="sse") as url:
|
||||
yield f"{url}/sse"
|
||||
|
|
@ -161,6 +161,6 @@ class TestTimeout:
|
|||
"""
|
||||
async with Client(
|
||||
transport=SSETransport(sse_server),
|
||||
timeout=0.01,
|
||||
timeout=0.1,
|
||||
) as client:
|
||||
await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
|
||||
await client.call_tool("sleep", {"seconds": 0.01}, timeout=2)
|
||||
|
|
|
|||
|
|
@ -225,9 +225,9 @@ class TestTimeout:
|
|||
with pytest.raises(McpError, match="Timed out"):
|
||||
async with Client(
|
||||
transport=StreamableHttpTransport(streamable_http_server),
|
||||
timeout=0.1,
|
||||
timeout=0.02,
|
||||
) as client:
|
||||
await client.call_tool("sleep", {"seconds": 0.2})
|
||||
await client.call_tool("sleep", {"seconds": 0.05})
|
||||
|
||||
async def test_timeout_tool_call(self, streamable_http_server: str):
|
||||
async with Client(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import socket
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -22,3 +23,37 @@ def import_rich_rule():
|
|||
|
||||
def get_fn_name(fn: Callable[..., Any]) -> str:
|
||||
return fn.__name__ # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def worker_id(request):
|
||||
"""Get the xdist worker ID, or 'master' if not using xdist."""
|
||||
return getattr(request.config, "workerinput", {}).get("workerid", "master")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def free_port():
|
||||
"""Get a free port for the test to use."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
return port
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def free_port_factory(worker_id):
|
||||
"""Factory to get free ports that tracks used ports per test session."""
|
||||
used_ports = set()
|
||||
|
||||
def get_port():
|
||||
while True:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.listen(1)
|
||||
port = s.getsockname()[1]
|
||||
if port not in used_ports:
|
||||
used_ports.add(port)
|
||||
return port
|
||||
|
||||
return get_port
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ def no_return_tool_result_factory(arg1: str) -> CallToolRequestResult:
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def live_server_with_tool() -> FastMCP:
|
||||
"""Fixture to create a FastMCP server instance with the echo_tool registered."""
|
||||
server = FastMCP()
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ def run_mcp_server(host: str, port: int) -> None:
|
|||
mcp.run(host=host, port=port, transport="http")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def mcp_server_url() -> Generator[str]:
|
||||
with run_server_in_process(run_mcp_server) as url:
|
||||
yield f"{url}/mcp"
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ def run_mcp_server(host: str, port: int) -> None:
|
|||
mcp.run(host=host, port=port, transport="http")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def mcp_server_url() -> Generator[str]:
|
||||
with run_server_in_process(run_mcp_server) as url:
|
||||
yield f"{url}/mcp"
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ def run_mcp_server(
|
|||
mcp.run(host=host, port=port, **run_kwargs or {})
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture
|
||||
def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
|
||||
with run_server_in_process(
|
||||
run_mcp_server,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class MockOAuthProvider:
|
|||
- Network calls to external services
|
||||
"""
|
||||
|
||||
def __init__(self, port: int = 9999):
|
||||
def __init__(self, port: int = 0):
|
||||
self.port = port
|
||||
self.base_url = f"http://localhost:{port}"
|
||||
self.app = None
|
||||
|
|
@ -229,9 +229,20 @@ class MockOAuthProvider:
|
|||
|
||||
async def start(self):
|
||||
"""Start the mock OAuth server."""
|
||||
import socket
|
||||
|
||||
from uvicorn import Config, Server
|
||||
|
||||
self.app = self.create_app()
|
||||
|
||||
# If port is 0, find an available port
|
||||
if self.port == 0:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
s.listen(1)
|
||||
self.port = s.getsockname()[1]
|
||||
|
||||
self.base_url = f"http://localhost:{self.port}"
|
||||
config = Config(self.app, host="localhost", port=self.port, log_level="error")
|
||||
self.server = Server(config)
|
||||
|
||||
|
|
@ -239,13 +250,13 @@ class MockOAuthProvider:
|
|||
asyncio.create_task(self.server.serve())
|
||||
|
||||
# Wait for server to be ready
|
||||
await asyncio.sleep(0.5)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the mock OAuth server."""
|
||||
if self.server:
|
||||
self.server.should_exit = True
|
||||
await asyncio.sleep(0.1)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
def reset(self):
|
||||
"""Reset all state for next test."""
|
||||
|
|
@ -308,7 +319,7 @@ def oauth_proxy(jwt_verifier):
|
|||
@pytest.fixture
|
||||
async def mock_oauth_provider():
|
||||
"""Create and start a mock OAuth provider."""
|
||||
provider = MockOAuthProvider(port=9999)
|
||||
provider = MockOAuthProvider()
|
||||
await provider.start()
|
||||
yield provider
|
||||
await provider.stop()
|
||||
|
|
|
|||
|
|
@ -42,13 +42,13 @@ def run_server(host: str, port: int, **kwargs) -> None:
|
|||
fastmcp_server().run(host=host, port=port, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
@pytest.fixture(autouse=True)
|
||||
def shttp_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="http") as url:
|
||||
yield f"{url}/mcp"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
@pytest.fixture(autouse=True)
|
||||
def sse_server() -> Generator[str, None, None]:
|
||||
with run_server_in_process(run_server, transport="sse") as url:
|
||||
yield f"{url}/sse"
|
||||
|
|
|
|||
|
|
@ -155,15 +155,15 @@ def timing_server():
|
|||
|
||||
@mcp.tool
|
||||
def short_task() -> str:
|
||||
"""A task that takes 0.1 seconds."""
|
||||
time.sleep(0.1)
|
||||
return "Done after 0.1s"
|
||||
"""A task that takes 0.01 seconds."""
|
||||
time.sleep(0.01)
|
||||
return "Done after 0.01 seconds"
|
||||
|
||||
@mcp.tool
|
||||
def medium_task() -> str:
|
||||
"""A task that takes 0.15 seconds."""
|
||||
time.sleep(0.15)
|
||||
return "Done after 0.15s"
|
||||
"""A task that takes 0.02 seconds."""
|
||||
time.sleep(0.02)
|
||||
return "Done after 0.02 seconds"
|
||||
|
||||
@mcp.tool
|
||||
def failing_task() -> str:
|
||||
|
|
@ -173,14 +173,14 @@ def timing_server():
|
|||
@mcp.resource("timer://test")
|
||||
def test_resource() -> str:
|
||||
"""A resource that takes time to read."""
|
||||
time.sleep(0.05)
|
||||
return "Resource content after 0.05s"
|
||||
time.sleep(0.005)
|
||||
return "Resource content after 0.005 seconds"
|
||||
|
||||
@mcp.prompt
|
||||
def test_prompt() -> str:
|
||||
"""A prompt that takes time to generate."""
|
||||
time.sleep(0.08)
|
||||
return "Prompt content after 0.08s"
|
||||
time.sleep(0.008)
|
||||
return "Prompt content after 0.008 seconds"
|
||||
|
||||
return mcp
|
||||
|
||||
|
|
|
|||
|
|
@ -345,12 +345,12 @@ async def test_multi_client_lifespan(tmp_path: Path):
|
|||
with pytest.raises(psutil.NoSuchProcess):
|
||||
while True:
|
||||
psutil.Process(pid_1)
|
||||
await asyncio.sleep(0.1)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
with pytest.raises(psutil.NoSuchProcess):
|
||||
while True:
|
||||
psutil.Process(pid_2)
|
||||
await asyncio.sleep(0.1)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue