claude-mem 的 Toast 通知单元测试套件设计:设置开关、平台守卫与错误韧性验证
本篇围绕 claude-mem 仓库中 Maestro 工作流的 Phase 02 playbook(Phase-02-Toast-Notification-Tests.md)展开,完整复盘其 ToastNotifier 通知模块单元测试套件的设计思路:如何用 mock 隔离外部依赖、如何覆盖设置开关 / 平台守卫 / 空值回退 / 错误韧性四个测试维度,以及如何运行与回归验证。读完后你不仅理解这套测试的每个断言背后的意图,也获得一份可复用到类似"fire-and-forget 通知模块"的测试设计模板。
1. 背景:ToastNotifier 模块在观察保存流水线中的位置
Phase 02 是紧接 Phase-01-Toast-Notification-Service.md 之后的测试阶段。Phase 01 定义了被测模块 ToastNotifier 的完整行为契约,这是理解 Phase 02 每个测试用例的前提:
- 依赖:通过
node-notifier(v10.0.1)调用 macOS 原生通知,类型声明来自 devDependency 中的@types/node-notifier(v8.0.5); - 配置开关:
CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED,写入~/.claude-mem/settings.json,默认值'false',由 SettingsDefaultsManager.ts 统一加载与校验; - 核心函数:
sendObservationToast(title: string | null, subtitle: string | null): void,行为约定为:process.platform !== 'darwin'时立即返回(仅 macOS 生效);- 通过
SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH)加载用户设置; - 开关不为
'true'时直接返回(需同时兼容字符串'true'与布尔值true两种取值形态); - 调用
notifier.notify({ title: title || 'Observation saved', message: subtitle || '', sound: false }); - 整个函数体包裹在 try/catch 中——错误仅通过
logger.warn('TOAST', ...)记录,绝不向外抛出,保证通知失败不会打断观察保存流水线(fire-and-forget 模式)。
- 接入点:在 ResponseProcessor.ts 的
syncAndBroadcastObservations()观察遍历循环中,于broadcastObservation(worker, {...})调用之后追加sendObservationToast(obs.title, obs.subtitle),与 Chroma 同步、folder CLAUDE.md 更新同属"尽力而为"旁路。
被测模块的形态(函数式模块而非 class,JSDoc 风格对齐 ObservationBroadcaster.ts)也决定了测试的组织方式:直接对导出的 sendObservationToast 做黑盒行为测试,通过 mock 切断它对 OS、文件系统和网络层(node-notifier)的触碰。
2. 测试目标:不依赖手动 QA 的四个验证维度
Playbook 开篇即给出该测试套件的定位:"Tests verify the setting toggle, platform guard, error resilience, and correct notification content — ensuring the feature is robust without manual QA." 即在不触发真实系统通知、不读写真实配置文件的前提下,验证四个维度:
| 维度 | 回答的问题 | 对应测试用例 |
|---|---|---|
| 设置开关(setting toggle) | CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED 的 true/false 是否真的控制了行为 |
用例 1、2 |
| 平台守卫(platform guard) | 非 macOS 平台是否被第一行守卫拦下 | 用例 3 |
| 错误韧性(error resilience) | 通知系统抛错时函数是否保持"永不抛出"契约 | 用例 6 |
| 内容正确性(notification content) | title/subtitle 为 null 时回退值是否正确 | 用例 4、5 |
Playbook 明确要求在动手前先"检查 tests/services/worker/agents/ 下既有测试文件的 import 模式与测试约定",再落笔——这是保持测试风格与仓库一致性的前置步骤。
3. Mock 策略:三层依赖隔离 + 平台常量替换
最终提交(git 历史提交 052e7bd2,"MAESTRO: add ToastNotifier unit tests with 6 test cases")中的测试文件头注释完整交代了 mock 依据,这是理解整套测试的关键:
/**
* Mock Justification:
* - node-notifier: External native notification library — mocked to capture calls without triggering OS notifications
* - SettingsDefaultsManager: File I/O dependency — mocked to control the toast setting value
* - process.platform: Runtime constant — overridden via Object.defineProperty for platform guard testing
*/
具体实现分四部分(以下代码摘自该提交的测试文件 tests/services/worker/agents/ToastNotifier.test.ts,共 137 行):
(1)捕获型 mock:node-notifier。 不 stub 返回值,而是用 mock() 记录 notify() 的每次调用参数,供断言"是否调用、以什么参数调用":
const mockNotify = mock(() => {});
mock.module('node-notifier', () => ({
default: { notify: mockNotify },
}));
(2)可变的设置值 mock:SettingsDefaultsManager。 通过闭包变量 mockSettingValue 让每个用例独立控制开关取值,避免测试间状态污染:
let mockSettingValue: string | boolean = 'true';
mock.module('../../../../src/shared/SettingsDefaultsManager.js', () => ({
SettingsDefaultsManager: {
loadFromFile: mock(() => ({
CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED: mockSettingValue,
})),
},
}));
(3)静默与去环境依赖 mock:logger 与 paths。 logger mock 吞掉测试期间的日志输出;paths mock 把 USER_SETTINGS_PATH 指到 /tmp/fake-settings.json,确保测试不触碰真实用户配置:
mock.module('../../../../src/utils/logger.js', () => ({
logger: { info: mock(() => {}), warn: mock(() => {}), error: mock(() => {}), debug: mock(() => {}) },
}));
mock.module('../../../../src/shared/paths.js', () => ({
USER_SETTINGS_PATH: '/tmp/fake-settings.json',
}));
注意 mock 必须在 import { sendObservationToast } from '...ToastNotifier.js' 之前声明,模块解析时才能拿到替换后的实现——这也是 Phase 01 文档中"mock 先行、import 后置"约定的直接体现。
(4)平台常量替换:Object.defineProperty(process, 'platform', ...)。 process.platform 是运行时只读常量,Playbook 给出的两种手段(vi.spyOn 或 defineProperty)中,最终实现采用了后者,并在 beforeEach 默认置为 'darwin'、afterEach 恢复原值,保证每个用例都从"macOS + 已启用"的基线出发:
describe('ToastNotifier', () => {
const originalPlatform = process.platform;
beforeEach(() => {
mockNotify.mockClear();
mockSettingValue = 'true';
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
4. 六个测试用例逐一拆解
Playbook 逐条规定的六个用例与最终实现一一对应。下面按"前置状态 → 断言意图"拆解:
用例 1:sends notification when enabled on macOS —— 设置 'true'、平台 darwin,调用 sendObservationToast('Title', 'Subtitle'),断言 notify 恰好被调用一次且参数精确匹配。注意断言用的是 toHaveBeenCalledWith 的整体对象匹配,连 sound: false 都被锁定,防止将来有人随手开启声音:
sendObservationToast('Title', 'Subtitle');
expect(mockNotify).toHaveBeenCalledTimes(1);
expect(mockNotify).toHaveBeenCalledWith({
title: 'Title',
message: 'Subtitle',
sound: false,
});
用例 2:does not send notification when disabled —— 仅把 mockSettingValue 置为 'false',断言 notify 从未被调用。这验证的是 Phase 01 契约中"开关不为 'true' 即返回"的短路逻辑。
用例 3:does not send notification on non-macOS platforms —— 设置仍为 'true',但把平台覆写为 'linux':
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
mockSettingValue = 'true';
sendObservationToast('Title', 'Subtitle');
expect(mockNotify).not.toHaveBeenCalled();
它证明平台守卫独立于设置开关生效:即使用户在 settings.json 里打开了开关,Linux/Windows 上也不会有任何通知尝试——这是 node-notifier 在 macOS 之外行为不可控时最重要的防线。
用例 4:handles null title gracefully —— 传 (null, 'subtitle'),断言回退标题 'Observation saved' 生效:
sendObservationToast(null, 'subtitle');
expect(mockNotify).toHaveBeenCalledWith({
title: 'Observation saved',
message: 'subtitle',
sound: false,
});
用例 5:handles null subtitle gracefully —— 传 ('title', null),断言 message 回退为空字符串 ''。两条 null 回退用例合起来锁死了 title || 'Observation saved' / subtitle || '' 这两个表达式,正是"内容正确性"维度的全部。
用例 6:does not throw when notifier errors —— 错误韧性维度的核心:让 mock 直接抛错,断言被测函数吞掉异常:
mockSettingValue = 'true';
mockNotify.mockImplementation(() => {
throw new Error('notification system unavailable');
});
// Should not throw
expect(() => sendObservationToast('Title', 'Subtitle')).not.toThrow();
这条用例守护的是整个模块的存在理由:sendObservationToast 内嵌在观察保存主流程里,若它抛出异常,一次"通知系统不可用"就会打断整批观察的保存。从源码结构看,Phase 01 要求"try/catch 包裹整个函数体、logger.warn('TOAST', ...) 记录但不重抛",正是为了让本用例可以通过。
5. 运行、修复与全量回归
Playbook 的第二、三个任务规定了验证闭环,也是这类"新增设置项 + 新增模块"变更的标准操作:
- 单文件验证:
npx vitest run tests/services/worker/agents/ToastNotifier.test.ts——出现失败时按"调整 mock 策略"的指引迭代,直到 6 个用例全绿。 - 全量回归:
npx vitest run。Playbook 明确记录了两条现实约束:- 当时存在 24 个既有失败(涉及 logger-usage-standards、worker-spawn、integration tests 等),且均与 ToastNotifier / toast 设置无关——回归验证的目标是"不引入新失败",而非清零全部存量问题;
- 若既有测试因
SettingsDefaultsManager新增设置项而失败,应更新测试夹具,在其中补充CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED: 'false'——这提示:向 SettingsDefaultsManager.ts 的默认值对象添加新键时,凡是做默认值快照断言的测试都需要同步维护夹具。
- 完成标准:"All tests must pass before completing this phase"。
6. 仓库中的实现证据与当前状态说明
- Phase 02 playbook 本身(Phase-02-Toast-Notification-Tests.md)与配套的 Phase-01-Toast-Notification-Service.md 均保留了三个任务条目的
[x]完成标记,说明该阶段已按清单执行完毕。 - 测试文件
tests/services/worker/agents/ToastNotifier.test.ts(137 行)与依赖安装(node-notifier@10.0.1、@types/node-notifier@8.0.5)分别落在 git 历史提交052e7bd2与ca739ad5中;被测模块与ResponseProcessor.ts的接线、SettingsDefaultsManager的接口/DEFAULTS/布尔校验三处新增均由 Phase 01 文档记录。 - 需要说明的是:在当前主干代码中,
ResponseProcessor.ts与SettingsDefaultsManager.ts已查不到sendObservationToast/CLAUDE_MEM_TOAST_NOTIFICATIONS_ENABLED的痕迹,tests/services/worker/下也不再有该测试文件——可以推断该功能在此后经历了合并或重构调整,本文所引用的模块与测试代码以 git 历史中的提交内容为准。这一点本身也提醒读者:仓库中引用的文件路径应以当前工作树实际存在为准。
7. 从这份 Playbook 提炼的可复用测试模式
这个 6 用例的套件虽小,却完整演示了为"旁路通知类"模块设计测试的通用套路:
- 用 mock 记录器而非 mock 返回值:对
notifier.notify这类"副作用型外部调用",断言应落在"是否调用 + 调用参数"上,toHaveBeenCalledWith的整对象匹配能顺带锁定sound: false这类容易漂移的细节参数; - 可变闭包变量驱动配置 mock:
mockSettingValue让同一套 mock 在不同用例中呈现不同配置,配合beforeEach重置形成干净的用例隔离; Object.defineProperty替换平台常量:这是测试"平台守卫"分支最可靠的手段,且必须afterEach还原,否则污染其他测试文件;- 把"永不抛出"写成显式用例:fire-and-forget 契约只写在 JSDoc 里是防不住回归的,
expect(() => ...).not.toThrow()才能把它固化进测试; - Playbook 式测试设计文档的价值:Phase 02 文档把每个用例的"前置状态 + 调用 + 断言"写成了可直接照抄的执行规格,并提前声明了"既有失败不计入回归"的判界——这种"先写测试规格、再写测试代码"的流程,让实现与验证各自可审查,是小功能上保证质量而不做手动 QA 的务实做法。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00