things-mcp 测试体系拆解:192 个单元测试,外加一份让 Claude 自动执行的集成测试计划
things-mcp 测试体系拆解:192 个单元测试,外加一份让 Claude 自动执行的集成测试计划
things-mcp 是一个 Things.app MCP Server,让 Claude Desktop、ChatGPT 等 AI 客户端直接读写 macOS 上 Things 3 的任务数据。今天拆解它的测试体系:192 个 pytest 单元测试 + 一份让 Claude 自动执行的集成测试计划,两层互补,覆盖从 URL 拼接到真实 Things 数据库的全链路。
先认识被测对象:三个核心模块 🧩
things-mcp 的源码非常小巧,全部逻辑集中在 src/things_mcp/ 下的三个文件:
| 模块 | 职责 | 测试重点 |
|---|---|---|
| server.py | 26 个 MCP 工具的定义、分页、Someday 过滤 | 工具返回文本与结构化数据 |
| url_scheme.py | Things URL 拼接、AppleScript 执行 | URL 编码、转义、回退逻辑 |
| formatters.py | 把数据库对象格式化为可读文本 | 嵌套结构、时间戳、任务龄 |
测试体系就围绕这三个模块展开,再加上一个"文档级"的集成测试计划,构成两层防线:
things-mcp 测试金字塔
┌─────────────────────────────┐
│ 集成测试计划(Claude 执行) │ 真实 Things 数据库
├─────────────────────────────┤
│ 192 个 pytest 单元测试 │ 全 mock,无需 macOS
└─────────────────────────────┘
六大测试文件的分工:192 个测试各自守什么 📊
单元测试全部位于 tests/ 目录,共 6 个测试文件,分工明确:
| 测试文件 | 测试数 | 覆盖目标 |
|---|---|---|
| test_formatters.py | 79 | 格式化函数:todo/project/area/tag/heading、任务龄计算、时间戳 |
| test_url_scheme.py | 48 | URL 构建、osascript 调用回退、AppleScript 转义 |
| test_things_server.py | 46 | 服务器工具:分页、structured_content 结构、Logbook 解析 |
| test_someday_filtering.py | 8 | Someday 项目过滤的核心函数 |
| test_mcp_server_filtering.py | 7 | MCP 工具层的过滤集成行为 |
| test_things_server_headings.py | 4 | 项目标题(headings)工具 |
合计正好 192 个(CLAUDE.md 中也有同样记录)。可以看到测试数量与模块复杂度成正比:格式化逻辑最复杂,测试也最多;headings 功能较薄,4 个测试即可覆盖。
一键运行:things-mcp 单元测试怎么跑 🏃
测试依赖独立声明为可选依赖(pyproject.toml 中的 test extra),pytest 配置也在同一文件里(pyproject.toml),开箱即用:
# 安装含测试依赖的环境
uv sync --extra test
# 运行全部测试
uv run pytest
# 详细输出 / 只跑某个文件 / 按名称匹配
uv run pytest -v
uv run pytest tests/test_url_scheme.py
uv run pytest -k "test_add_todo"
一个值得注意的配置是 asyncio_mode = "auto"——MCP 工具函数是异步的,开启该模式后测试无需逐个标注 @pytest.mark.asyncio 也能直接测协程。
关键设计 1:全量 Mock,让测试在没有 Things 的机器上跑 🧪
things-mcp 依赖 macOS、Things 3 数据库和 things.py 库,单元测试却能在任何 Linux CI 上运行,诀窍是把三个外部边界全部 mock 掉:
- 数据库读取:
patch('things.get')、patch('things.todos')等拦截所有数据访问; - Token 获取:
patch('things.token')返回固定 token,连认证都不需要真实环境(tests/conftest.py); - 系统调用:写操作走
subprocess.run执行osascript,测试直接 mock 该调用,只断言参数是否拼接正确(tests/test_url_scheme.py)。
例如执行 URL 的测试,断言的就是最终传给 osascript 的命令数组,连 shell 回退路径都单独覆盖。
关键设计 2:9 个共享 fixture + 1 个归一化助手 📦
公共测试数据集中在 tests/conftest.py,提供 9 个 fixture:
- 数据类:
mock_todo(含 checklist、tags 的完整任务)、mock_completed_todo、mock_project、mock_area、mock_tag; - mock 类:
mock_things_token、mock_things_get(按 UUID 返回不同结果)、mock_things_todos、mock_things_projects。
另有一个 13 行的小助手 tests/_helpers.py 中的 tool_text():因为 read 工具同时返回"人类可读文本 + structured_content"双通道,有时又是纯字符串,tool_text() 把两种形态归一化为文本,让断言代码不用关心返回形态。
💡 小文件大作用:这种"抹平返回值差异"的助手,是双通道 API 测试中最容易被忽略的一环。
关键设计 3:给"最容易被 UI 骗"的业务规则留了双份测试 🎯
things-mcp 有个不显眼的业务规则:Someday 项目里的任务不应出现在 Today / Upcoming / Anytime 视图(与 Things 原生 UI 行为一致)。这类"看起来对"的过滤逻辑最容易回归出错,作者用了两层测试:
- 函数层:test_someday_filtering.py 直接测
filter_someday_project_tasks,mock 掉项目列表,验证 Someday 任务被剔除、正常任务保留; - 工具层:test_mcp_server_filtering.py 测真实的 MCP 工具(如
get_anytime),构造"3 个任务分别属于 Someday 项目 / 活跃项目 / 无项目"的场景,断言只剩后两者。
两层各 8 个和 7 个测试,函数改错了工具层会红,工具组装错了函数层测不出来——互为兜底。
集成测试计划:一份写给 Claude 的"操作手册" 🤖
单元测试无法验证真实链路:Things URL 是否真的被 App 执行、视图隔离在真实数据库上是否成立。传统做法是写 Selenium/AppleScript 脚本,而 things-mcp 的做法更有意思——把集成测试写成一份结构化的 Markdown 计划,交给 Claude(Claude Cowork 或 Claude Code)通过 MCP 工具自己执行:
📄 docs/mcp_integration_test_plan.md(523 行,7 个阶段)
| 阶段 | 内容 | 检查项 |
|---|---|---|
| Phase 1 | 只读工具验证 | 全部列表/查询/搜索工具可响应;分页 limit/offset 双通道一致;越界 offset 单独提示(计划 1.4 节) |
| Phase 2 | 创建测试数据 | 1 个项目(含 2 任务)+ 3 个任务 + 1 个 Area,共 7 项,记录所有 UUID |
| Phase 3 | 验证创建结果 | 搜索命中、Someday 可见、Today/Upcoming/Anytime 隔离、提醒任务落入 Upcoming |
| Phase 4 | 更新操作 | 更新 todo/项目/Area;checklist 省略时保留、空列表时清空 |
| Phase 5 | UI 导航工具 | show_item 打开 Today 视图、search_items 呈现搜索结果 |
| Phase 6 | 清理 | 全部标记 canceled,搜索应查无结果;Area 需人工删除并登记 |
| Phase 7 | 人工核对清单 | Logbook 状态确认、可选永久删除、Area 手动删除 |
安全规则:这套计划为什么敢碰真实数据 🔒
计划开头专门定义了 6 条安全规则:
- 所有测试项必须带
[MCP-TEST]前缀,一眼可辨; - 一律使用
when: "someday",绝不污染用户的 Today/Upcoming 列表(短暂的正向存在性测试除外,测完立即移回 Someday); - 绝不修改用户已有数据,只操作自己创建的项;
- 全程追踪 UUID,Phase 6 强制清理——即使前面阶段失败也必须执行清理;
- 已知
add_area无法通过 MCP 删除(Things 中删 Area 会连带删项目,属设计决策),所以测试 Area 明确标注"需人工删除"并在 Phase 7 列出。
一个细节:连 2048 年都不能用 📅
计划里还埋了一个冷门知识点(Phase 2.4 注释):Things 用打包整数存储日期,年份字段上限是 2047。所以提醒任务不能用 2099 这类"哨兵日期",虽然排序上仍是"未来",但 things.py 解不出来,结构化字段会变 null。测试统一改用 2030 年——这类坑只有对着真实数据库反复测试才会暴露,也正是这份集成计划的价值所在。CHANGELOG.md 中多个 bug fix(Logbook 漏 canceled 项、get_recent 时区偏移、checklist 空列表不生效)都能对应到计划里的检查项。
这套体系对普通开发者的 3 点启发 ✅
- 分层克制:核心函数用函数层测试保证确定性,业务行为再用工具层测试兜底,避免"一个测试文件测所有东西"的泥潭;
- 把环境依赖 mock 在边界上:只 mock
things.*和subprocess.run这两个外部入口,内部逻辑全走真实代码; - 没有 CI 客户端?让 AI 当测试执行器:当被测对象必须通过 MCP 协议调用时,把测试写成"带勾选框 + 安全规则 + 完成条件"的文档,交给 AI 逐步执行并回报,是一种低成本且可维护的集成验证方式。
相关路径速查 📁
| 资料 | 路径 |
|---|---|
| 单元测试目录 | tests/ |
| 共享 fixture | tests/conftest.py |
| 双通道归一化助手 | tests/_helpers.py |
| 集成测试计划 | docs/mcp_integration_test_plan.md |
| pytest 配置 | pyproject.toml |
| AI 协作开发指引 | CLAUDE.md |
| 版本与修复记录 | CHANGELOG.md |
想本地体验完整流程,可直接克隆仓库 https://gitcode.com/gh_mirrors/th/things-mcp,然后 uv sync --extra test && uv run pytest,30 秒内就能看到 192 个测试全绿的输出。