FastMCP 项目 Python 测试最佳实践:基于 pytest 编写高效、可靠测试的完整指南
【免费下载链接】fastmcp🚀 The fast, Pythonic way to build MCP servers and clients.项目地址: https://gitcode.com/GitHub_Trending/fa/fastmcp
本指南以 FastMCP 仓库内的.claude/skills/python-tests/SKILL.md为骨架,全面讲解在 FastMCP 项目(MCP 服务器与客户端的高效 Pythonic 构建框架)中如何编写与评估高质量 pytest 测试。你将掌握原子化测试设计、参数化、fixtures、mock 边界、异步测试、快照测试以及仓库特有的测试规范(asyncio_mode = "auto"、内存传输等),并了解这些规则在真实源码与测试文件中的落地方式,可直接用于编写、评审和调试本项目的测试代码。
核心原则:原子性、自包含、单一职责
每个测试都应该验证单一功能(single functionality),并且是原子化(atomic)、自包含(self-contained)的。一个同时测试多个行为的测试,一旦失败,你无法立刻判断是哪个环节出了问题,调试和维护成本都会成倍增加。
这条原则贯穿 FastMCP 的整个测试体系。以 tests/conftest.py 中提供的fastmcp_serverfixture 为例,它只负责一件事:构建一个带有 tool、resource、prompt 的 FastMCP 测试服务器;而tool_serverfixture 则专注于构造覆盖多种返回类型(TextContent、ImageContent、EmbeddedResource等)的工具集合。两个 fixture 职责分离,测试代码按需取用,这正是"单一功能"思想在 fixture 层面的体现。
测试结构设计
原子化单元测试
每个测试验证一个行为,测试函数名应当在你失败时直接告诉你"是什么坏了"。同一行为内部的多个断言是允许的,但跨越多个行为的断言应当拆分到不同测试中:
# 好:名称直接指出失败点 def test_user_creation_sets_defaults(): user = User(name="Alice") assert user.role == "member" assert user.id is not None assert user.created_at is not None # 坏:如果失败,是哪个行为坏了? def test_user(): user = User(name="Alice") assert user.role == "member" user.promote() assert user.role == "admin" assert user.can_delete_others()第二个例子把"创建用户默认值"和"用户提升权限"两个独立行为塞进了同一个测试,任何一步失败都会造成排查歧义。
在 FastMCP 仓库中,可以看到大量遵循该命名的真实测试,例如 tests/tools/test_standalone_decorator.py 中的test_tool_without_parens、test_tool_with_name_arg、test_from_function_preserves_decorator_metadata,以及 tests/tools/tool/test_tool.py 中的test_basic_function、test_meta_parameter、test_async_function。每个函数名都精确描述了被验证的行为场景。
用参数化覆盖同一概念的多个变体
当多个输入本质上属于"同一概念"的变体时,使用@pytest.mark.parametrize而不是复制粘贴测试函数:
import pytest @pytest.mark.parametrize("input,expected", [ ("hello", "HELLO"), ("World", "WORLD"), ("", ""), ("123", "123"), ]) def test_uppercase_conversion(input, expected): assert input.upper() == expected仓库中同样大量使用这一模式,例如 tests/tools/test_standalone_decorator.py 中的test_component_import_works_in_fresh_interpreter接收不同的语句参数做参数化验证,覆盖不同导入场景。
不同功能用独立测试
不要参数化不相关的行为。如果测试逻辑本身不同,就写成独立的测试函数。参数化只适用于"同一逻辑、不同输入"的情况;一旦分支逻辑出现,拆分测试比塞进参数列表更清晰、更易维护。
项目特有规则
FastMCP 仓库在 pyproject.toml 中为 pytest 配置了一系列全局默认值,这直接决定了测试写法。下面逐一说明这些规则及背后的实现依据。
无需 async 标记:asyncio_mode = "auto"
项目的[tool.pytest.ini_options]中设置了asyncio_mode = "auto"与asyncio_default_fixture_loop_scope = "function"(pyproject.toml)。这意味着 pytest 会自动识别 async 测试函数并为其创建事件循环,无需(也不应)添加@pytest.mark.asyncio装饰器:
# 正确 async def test_async_operation(): result = await some_async_function() assert result == expected # 错误 —— 不要加这个 @pytest.mark.asyncio async def test_async_operation(): ...这一约定在仓库测试中全面落地。例如 examples/testing_demo/tests/test_server.py 的 fixture 注释明确写着"No@pytest.mark.asyncioneeded -asyncio_mode = "auto"handles it",其中的test_add_tool、test_greet_tool_default等测试均直接以async def编写。
模块级导入
所有 import 放在文件顶部。这保证测试运行时的导入顺序可预测、报错位置清晰,也避免重复导入带来的性能损耗:
# 正确 import pytest from fastmcp import FastMCP from fastmcp.client import Client async def test_something(): mcp = FastMCP("test") ... # 错误 —— 不要局部导入 async def test_something(): from fastmcp import FastMCP # Don't do this ...注意,仓库的 pyproject.toml 配置了pythonpath = ["fastmcp_slim", "fastmcp_remote", "fastmcp_tasks"],因此测试文件顶部可以直接from fastmcp import FastMCP或from fastmcp.client import Client导入仓库主包。而 tests/conftest.py 中部分 fixture 之所以使用函数内 import(如from fastmcp import FastMCP),是出于"避免导入时副作用"的专门考虑(见 examples/testing_demo/tests/test_server.py 中的说明),属于例外情形;日常测试仍应遵循模块级导入。
测试优先使用内存传输(in-memory transport)
测试 MCP 服务器时,直接把 FastMCP 服务器实例传给 Client,走进程内内存传输,而不是启动 HTTP 或 stdio 网络链路:
from fastmcp import FastMCP from fastmcp.client import Client mcp = FastMCP("TestServer") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" async def test_greet_tool(): async with Client(mcp) as client: result = await client.call_tool("greet", {"name": "World"}) assert result[0].text == "Hello, World!"只有当你显式测试网络特性时才使用 HTTP 传输。这一规则的实现依据来自 fastmcp_slim/fastmcp/client/client.py 的Client类:其transport参数接受ClientTransport实例、FastMCP服务器对象、URL 字符串、Path、MCPConfig或字典等多种来源,其中直接传入FastMCP即触发内存传输,连接开销几乎为零、无需网络端口,天然适合单元测试。
复杂数据用内联快照(inline-snapshot)
对于 JSON Schema 等复杂结构的断言,使用inline-snapshot库,首次运行自动填充期望值,之后每次变更一目了然:
from inline_snapshot import snapshot def test_schema_generation(): schema = generate_schema(MyModel) assert schema == snapshot() # 首次运行会自动填充常用命令:
pytest --inline-snapshot=create—— 填充空的快照pytest --inline-snapshot=fix—— 在有意修改后更新快照
仓库对此提供了完整的工程化支撑:依赖声明为inline-snapshot[dirty-equals]>=0.27.2(pyproject.toml,当前锁定版本 0.35.2),并在addopts中默认--inline-snapshot=disable(pyproject.toml),保证 CI 中快照不会被无意改写。真实用例可参考 tests/tools/tool/test_tool.py:test_basic_function将Tool.from_function(add)的model_dump(exclude_none=True)结果与snapshot({...})做全量比较,期望值内联在测试文件中,包含了 name、description、parameters、output_schema 等完整结构。类似的快照断言还广泛出现在 tests/server/middleware/test_caching.py、tests/tools/tool/test_output_schema.py、tests/utilities/openapi/test_models.py 等文件中。
Fixtures
优先使用函数级作用域的 fixture
默认的 function 作用域足够覆盖绝大多数场景——每个测试获得独立的 fixture 实例,避免状态串扰:
@pytest.fixture def client(): return Client() async def test_with_client(client): result = await client.ping() assert result is not None一个值得参考的完整范例在 examples/testing_demo/tests/test_server.py:其中的clientfixture 用async with Client(mcp) as client:包装后yield client,测试函数直接以async def test_add_tool(client: Client)形式使用。
需要说明的是,作用域选择应"按需而非一刀切"。仓库 tests/conftest.py 展示了何时应升级作用域:_settings_home_root、rsa_key_pair、otel_trace_provider等标注为scope="session",理由包括 RSA 密钥生成耗时数十毫秒、TracerProvider 每进程只能设置一次等,属于明确的性能或约束驱动;而isolate_settings_home因要隔离每个测试的settings.home目录(避免 oauth-proxy 存储目录因相同的jwt_signing_key指纹冲突导致状态泄漏)而保持 autouse + function 级。默认 function 作用域,仅在确有全局成本或全局约束时才提升作用域,是这一节的最佳实践总结。
文件操作用tmp_path
需要读写文件的测试,使用 pytest 内置的tmp_pathfixture,每个测试获得独立的临时目录:
def test_file_writing(tmp_path): file = tmp_path / "test.txt" file.write_text("content") assert file.read_text() == "content"仓库中tmp_path被广泛使用,例如 tests/utilities/test_version_check.py、tests/utilities/test_types.py 等。
Mocking
在边界处 mock
Mock 应当发生在系统边界——外部 API、网络调用、第三方服务;你的业务代码内部不要 mock:
from unittest.mock import patch, AsyncMock async def test_external_api_call(): with patch("mymodule.external_client.fetch", new_callable=AsyncMock) as mock: mock.return_value = {"data": "test"} result = await my_function() assert result == {"data": "test"}异步函数必须使用AsyncMock(或new_callable=AsyncMock),否则await会直接报错。FastMCP 仓库的 auth 提供者测试大量实践了这一模式,例如 tests/server/auth/providers/test_discord.py、tests/server/auth/providers/test_propelauth.py 等,对 OAuth 提供商的外部请求做 patch,而自身服务逻辑使用真实实现。
不要 mock 你拥有的代码
测试自己的代码时尽量使用真实实现,只 mock 外部服务,而不是内部类。这也与前面的内存传输规则一脉相承:FastMCP 的服务器与客户端可以直接在进程内真实交互(见 tests/conftest.py 的fastmcp_serverfixture 与各类Client(mcp)用例),无需 mock 协议层。
测试命名
使用能说明场景的描述性名称。好的测试名本身就是失败时的诊断信息:
# 好 def test_login_fails_with_invalid_password(): def test_user_can_update_own_profile(): def test_admin_can_delete_any_user(): # 坏 def test_login(): def test_update(): def test_delete():仓库中的命名规范与此一致——如 tests/tools/tool/test_tool.py 的test_meta_parameter(验证 meta 参数正确处理)、test_async_function(验证异步函数注册与运行),以及 tests/tools/test_standalone_decorator.py 的test_tool_with_name_kwarg。命名应当包含"被测对象 + 场景/预期行为"两个要素。
错误测试
用pytest.raises断言异常,并通过match参数收紧匹配内容,避免"抛了错就算过"的假阳性:
import pytest def test_raises_on_invalid_input(): with pytest.raises(ValueError, match="must be positive"): calculate(-1) async def test_async_raises(): with pytest.raises(ConnectionError): await connect_to_invalid_host()注意异步错误测试同样不需要任何 async 标记,asyncio_mode = "auto"会自动处理。
运行测试
项目使用uv作为包与运行环境管理,常用命令如下:
uv run pytest -n auto # 并行运行全部测试 uv run pytest -n auto -x # 遇到首个失败即停止 uv run pytest path/to/test.py # 只运行指定文件 uv run pytest -k "test_name" # 按名称模式运行 uv run pytest -m "not integration" # 排除集成测试-n auto依赖 xdist 并行分发。仓库为并行场景做了专门设计:除了 pyproject.toml 中定义的integration、client_process、subprocess_heavy、conformance四个 marker 外,tests/conftest.py 还通过pytest_collection_modifyitems自动为integration_tests目录下的测试打上integration标记,并提供了worker_id、free_port、free_port_factory等 fixture 支持 xdist 工作进程下的端口管理与隔离。建议并行运行前先了解这些 marker 的语义(例如subprocess_heavy标记的测试会串行执行以避开并行争抢)。
其他值得注意的全局配置(pyproject.toml):
filterwarnings:将 "coroutine was never awaited" 等异步隐患升级为错误,提前暴露资源泄漏;timeout = 5:每个测试的全局超时(秒),防止测试卡死拖垮整个套件;env:注入FASTMCP_TEST_MODE=1等测试环境变量;testpaths = ["tests"]:默认只收集 tests 目录;python_files/python_classes/python_functions:默认收集test_*.py、Test*类、test_*函数。
提交前自检清单
在提交测试代码前,逐项核对以下清单(对应 .claude/skills/python-tests/SKILL.md 的原始要求):
- 每个测试只测一件事(原子化)
- 没有
@pytest.mark.asyncio装饰器(asyncio_mode = "auto") - 所有 import 位于模块顶部
- 测试名描述性、能定位失败点
- 使用内存传输而非 HTTP(除非在测试网络特性)
- 同一行为的变体用参数化
- 不同行为写成独立测试
附:测试能力相关仓库资源索引
以下是本仓库中与本指南相关的核心文件,可继续深入研读:
- 测试规范原文:.claude/skills/python-tests/SKILL.md
- pytest 全局配置(asyncio_mode、markers、timeout、addopts、pythonpath):pyproject.toml
- 全局 fixtures(fastmcp_server、tool_server、rsa_key_pair、free_port、isolate_settings_home 等)与自动打标逻辑:tests/conftest.py
- 快照断言的完整实战:tests/tools/tool/test_tool.py
- 完整可运行的测试示例项目(含 async fixtures 与内存传输用法):examples/testing_demo/tests/test_server.py 与 examples/testing_demo/README.md
Client的传输参数说明(内存传输与 HTTP 的取舍依据):fastmcp_slim/fastmcp/client/client.py
【免费下载链接】fastmcp🚀 The fast, Pythonic way to build MCP servers and clients.项目地址: https://gitcode.com/GitHub_Trending/fa/fastmcp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考