☰
FastMCP 项目 Python 测试最佳实践:基于 pytest 编写高效、可靠测试的完整指南
2026/10/7 23:57:59 网站建设 项目流程

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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询