optimize test suite (#1893)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
nate nowack 2025-09-22 19:07:04 -05:00 committed by GitHub
commit 04831b75c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 88 additions and 40 deletions

View file

@ -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 }}

View file

@ -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")

View file

@ -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"

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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(

View file

@ -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

View file

@ -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()

View file

@ -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"

View file

@ -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"

View file

@ -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,

View file

@ -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()

View file

@ -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"

View file

@ -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

View file

@ -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(