关于 finding-seams
finding-seams 是帮助开发者测试含有隐藏依赖的代码的技能。它讲解如何引入范围最小的替换点,优先采用显式参数注入,并将模块模拟视为临时脚手架。
主要功能
- 01识别妨碍测试的隐藏依赖
- 02用函数参数建立替换点
- 03选择 TypeScript 和 JavaScript 的接缝类型
- 04保持生产默认值与调用点不变
- 05移除过时的依赖模拟
适用场景
- 01测试直接调用外部系统的代码
- 02隔离读取全局对象或单例的函数
- 03为遗留代码的行为刻画做准备
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill finding-seams安装后在 Claude Code 中输入/finding-seams确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
resources/creating-seams.mdoop-patterns.mdseam-types.md详细介绍
寻找接缝
有了接缝后,若要编写记录现有行为的测试,请加载 characterisation-tests 技能。若要通过测试驱动新行为,请加载 tdd 技能。若要了解通用测试模式,请加载 testing 技能。若要在测试就位后进行重构,请加载 refactoring 技能。确定模块长期职责及面向调用方的契约时,请使用 codebase-design;本技能介绍放置现有行为以供测试所需的最小启用点。
深入学习资源位于 resources/ 目录中。按需加载:
| 资源 | 在以下情况下加载…… |
|---|---|
seam-types.md | 需要 TypeScript 中每种接缝类型的详细 FP 优先示例 |
creating-seams.md | 需要在尚无接缝的地方引入接缝,并查看修改前后的示例 |
oop-patterns.md | 遇到基于类的遗留代码——对象接缝、子类化与重写、构造函数注入 |
核心概念
接缝是指可以在不修改该位置代码的情况下,改变程序行为的地方。
每个接缝都有一个启用点——用于选择要激活哪种行为的位置。生产环境和测试中的接缝处源代码保持不变;只有启用点不同。
接缝并不自动意味着公开的模块接口、端口或永久抽象。首先引入范围最窄且安全的替换点;行为特征明确后,再由 codebase-design 决定该点是否应纳入持久契约,或应保留为私有脚手架。
—— Michael Feathers,《Working Effectively with Legacy Code》(2004)
**与六边形架构的联系:**端口是预先设计好的接缝。端口定义契约(接缝),组合根则选择要接入哪个适配器(启用点)。如果你的代码已经采用六边形架构,那么接缝无处不在——本技能适用于缺少接缝的代码。请参阅 hexagonal-architecture 技能。
适用场景
- 在测试框架中无法调用某个函数,因为它会直接访问外部系统
- 函数将依赖硬编码,而不是将其作为参数接收
- 全局或静态依赖导致无法隔离
- 单例访问模式使代码与共享可变状态耦合
- React 组件在内部获取数据,而不是通过 props/context 接收数据
快速参考:TypeScript/JS 接缝类型
| 接缝类型 | 机制 | 启用点 | 优先用于 |
|---|---|---|---|
| 函数参数 | 将依赖作为参数传入 | 参数列表 | **默认选择。**适用于函数式代码、纯函数、显式契约 |
| 配置 | 将配置/环境值作为参数接收 | 接收这些值的函数的参数列表 | 适用于基础设施层面的关注点。在被测函数内部保留 process.env 读取属于隐藏依赖,而非配置接缝——应将其移出,并由默认实现负责读取 |
| 模块 | 使用 vi.mock() / jest.mock() 替换导入 | 测试文件的模拟配置 | **最后手段。**仅用于快速搭建脚手架——会绕过类型安全、依赖隐式,且需要清理。能够修改签名时就不要编写新的模块模拟,也不要照搬相邻测试中的模块模拟:现有模块模拟只是待迁移的脚手架,不是项目惯例 |
| 对象 | 子类化并重写,或通过构造函数进行 DI | 对象的创建位置 | 适用于基于类的遗留代码(参阅 resources/oop-patterns.md) |
为现有代码编写测试
在编写任何测试之前,先完成以下步骤:
- 列出该函数依赖的所有隐藏依赖——它会创建的协作者、
Date.now()/new Date()、process.env、单例。一个不漏,而不只是最让你头疼的那个。 - 将每个依赖移到参数列表中,并设置一个能精确复现当前行为的生产默认值——现在
new和环境变量读取都放在默认值中,因此生产代码的行为不变。完成后,被测函数不会创建任何对象,也不会读取任何全局变量。 - 编写测试时,将伪对象作为参数传入。伪对象是你在测试中手写、完全可控的替代物:例如
() => 1_700_000_000_000这样的字面量,或仅实现接缝狭窄类型所声明方法的小型对象。绝不要通过接缝传入真实协作者——不管它是生产客户端、驱动程序还是服务对象,也不管它的实现有多轻量、本地或基于内存,以及它自己的注释如何声称。如果测试基于真实协作者构建,那么该协作者的形状一旦变化,测试仍会失败;这正是接缝要消除的耦合。如果测试需要vi.mock()、vi.stubEnv()或 fake timers 才能运行,说明仍有依赖被隐藏:回到第 2 步,而不是使用这些手段。 - 移除现有测试中对该依赖的任何模块模拟——接缝已取代它,同时保留两者意味着旧测试仍然无法看到模拟所隐藏的内容。
交还工作时,请说明你引入的接缝类型,以及它的启用点所在位置(文件和参数默认值、?? 回退或工厂调用所在行),并确认现有调用点没有变化。
如何找到接缝
检查你需要测试的代码中是否有以下内容:
- 函数参数——任何可以接受不同实现的参数
- 默认参数值——
(resolve = fetchFromApi)已经是一个接缝 - 模块导入——任何导入的内容都可能被模拟(但优先采用参数注入)
- 配置——环境变量、配置文件、功能标志
- React props 和 context——组件通过 props 接收依赖;测试中可以替换 context provider
- 硬编码的
new或直接调用——每个直接依赖都可能成为接缝,但目前还不是
方案顺序
按优先级从高到低排列。先从可行方案中最明确的选项开始:
- 函数参数注入——将依赖作为参数传入,并提供生产默认值(显式、类型安全,无需框架)
- 高阶函数——通过工厂返回已配置的函数(FP 组合)
- 配置注入——将配置或环境作为参数传入,而不是全局读取
- 模块模拟——使用
vi.mock()替换导入(仅用于搭建——随着测试覆盖率提升,逐步迁移掉) - 继承并重写——仅用于遗留类代码(参见
resources/oop-patterns.md)
只有当长期契约值得保留时,步骤 1-3 才适合作为持久设计。否则,将它们保持为私有实现,或在 特征化之后移除。步骤 4-5 通常只是临时搭建手段。
简短示例
在对 scheduleDelivery 进行特征化之前,需要先为其隐藏依赖设置一个接缝:
// BEFORE -- no seam, can't test without hitting real API
const scheduleDelivery = (delivery: Delivery): DeliveryPlan => {
const transitDays = fetchTransitDays(delivery.region);
return { ...delivery, totalDays: delivery.preparationDays + transitDays };
};
// AFTER -- function parameter seam with production default
type TransitDaysResolver = (region: string) => number;
const scheduleDelivery = (
delivery: Delivery,
resolveTransitDays: TransitDaysResolver = fetchTransitDays,
): DeliveryPlan => {
const transitDays = resolveTransitDays(delivery.region);
return { ...delivery, totalDays: delivery.preparationDays + transitDays };
};
// Test -- swap in a fake at the enabling point (the argument list)
const result = scheduleDelivery(testDelivery, () => 2);
生产代码在每个调用点都保持不变(会使用默认值)。测试则传入伪对象。接缝就是参数;启用点位于参数列表中。
代码异味 → 技巧
| 你在代码中看到的情况 | 技巧 | 示例 |
|---|---|---|
函数内部有 new Foo() | 参数化函数 | 将依赖作为参数传入,并提供默认值 |
直接读取 process.env.X | 包装全局调用 | (getEnv = () => process.env.X) |
直接使用 import { thing } from './heavy-lib' | 提取类型 + 参数化 | 定义范围较窄的 type,并将其作为参数传入 |
| 一个函数中有多个硬编码依赖 | 高阶函数工厂 | createFn(deps) => (args) => result |
SingletonClass.getInstance() | 包装全局调用 | (getSingleton = () => SingletonClass.getInstance()) |
Date.now() / Math.random() | 包装全局调用 | 将 (now = Date.now) 作为参数传入 |
| 类自行构造协作者 | 参数化构造函数(OOP) | 通过构造函数接收,参见 oop-patterns.md |
| 暂时无法更改函数签名 | 模块间接层(脚手架) | 使用薄封装模块 + vi.mock(),之后再迁移 |
常见错误
| 错误 | 修正方法 |
|---|---|
将 vi.mock() 作为永久架构 | 模块模拟会绕过类型安全,并产生隐式耦合。有测试后应尽快迁移到参数注入。 |
| 优先采用基于类的模式(子类、DI 容器) | 在 TypeScript FP 中,函数参数能自然地提供接缝。很少需要类和 DI 容器。 |
| 不寻找真正的接缝,反而模拟一切 | 只在接缝边界进行模拟;测试真实逻辑 |
| 创建泄露实现细节的接缝 | 接缝接口应描述做什么,而非如何做 |
| 忘记启用点 | 每个接缝都需要一个选择行为的位置;如果没有启用点,它就不是接缝 |
| 一次拆分太多依赖 | 一次拆分一个依赖;先让测试通过,再拆分下一个 |