Finding Seams
Finding Seams 是一个用于在既有代码中寻找“接缝”(seam)的技能,目的是让遗留或紧耦合代码变得可测试,而无需在调用点进行修改。
用途
当现有代码存在无法测试的依赖时使用本技能,例如:直接构造协作者、静态或全局函数调用、与外部系统紧耦合、单例访问模式。它专门用于识别替换点(接缝),使遗留或紧耦合代码可被测试,而不必编辑调用点。
不适用于:全新项目的 TDD(参见 tdd)、一般测试编写模式(参见 testing)、已测试代码的重构(参见 refactoring)。
核心概念
接缝(seam)是程序中可以在不编辑该处的情况下改变行为的位置。
每个接缝都有一个“启用点”(enabling point)——即选择激活哪种行为的位置。接缝处的源代码在生产与测试中保持相同,只有启用点不同。
接缝不自动等于公共模块接口、端口或永久抽象。应先引入最窄的安全替换点;一旦行为被刻画,再由 codebase-design 决定该点是否属于持久契约,还是应保持为私有脚手架。
与六边形架构的联系:端口是设计好的接缝。端口定义契约(接缝),组合根选择接入哪个适配器(启用点)。如果代码已使用六边形架构,则处处都有接缝;本技能针对的是缺少接缝的代码。
何时使用
- 无法在测试框架中调用某函数,因为它直接访问外部系统
- 函数硬编码依赖,而不是将其作为参数接收
- 全局或静态依赖使隔离不可能
- 单例访问模式将代码耦合到共享可变状态
- React 组件内部获取数据,而不是通过 props/context 接收
TypeScript/JS 接缝类型速查
| 接缝类型 | 机制 | 启用点 | 优先使用场景 |
|---|---|---|---|
| 函数参数 | 将依赖作为参数传入 | 参数列表 | 默认选择。 函数式代码、纯函数、显式契约 |
| 配置 | 将配置/环境值作为参数接收 | 接收它们的函数的参数列表 | 基础设施级关注点。留在被测函数内的 process.env 读取是隐藏依赖,不是配置接缝——应移出并让默认值负责读取 |
| 模块 | vi.mock() / jest.mock() 替换导入 |
测试文件的 mock 配置 | 最后手段。 仅作快速脚手架——绕过类型安全、隐式、需要清理。能改签名就不要新建,也不要从邻近测试复制:现有模块 mock 是待迁移的脚手架,不是房屋模式 |
| 对象 | 子类覆盖,或通过构造函数 DI | 对象创建处 | 遗留类代码(参见 resources/oop-patterns.md) |
将既有代码置于测试之下
在编写任何测试之前,先完成以下步骤:
- 列出函数访问的每一个隐藏依赖——它构造的协作者、
Date.now()/new Date()、process.env、单例。全部列出,而不只是最烦人的那个。 - 将每个依赖移到参数列表,并带一个完全复现当前行为的生产默认值——
new和环境读取现在位于默认值中,因此生产行为不变。完成后,被测函数不构造任何东西,也不读取任何全局。 - 通过将假对象作为参数传入来编写测试。假对象是你在测试中手写并完全控制的替身:如
() => 1_700_000_000_000这样的字面量,或只实现接缝窄类型所命名方法的小对象。绝不要通过接缝传入真实协作者——无论是生产客户端、驱动还是服务对象,无论其实现多么廉价、本地或内存中,也无论其注释如何声称。基于真实协作者构建的测试仍会在该协作者形状变化时失败,而这正是接缝要切断的耦合。如果测试需要vi.mock()、vi.stubEnv()或假定时器才能运行,说明仍有依赖被隐藏:回到第 2 步,而不是使用它们。 - 移除现有测试中该依赖的任何模块 mock——接缝取代了它,两者都留意味着旧测试仍看不到 mock 所隐藏的内容。
交回工作时,说明你引入的接缝类型及其启用点位置(参数默认值的文件与行号、?? 回退或工厂调用),并确认现有调用点未变。
如何寻找接缝
在需要测试的代码中寻找以下内容:
- 函数参数——任何可以接受不同实现的参数
- 默认参数值——
(resolve = fetchFromApi)已经是一个接缝 - 模块导入——任何导入都可能被 mock(但优先参数注入)
- 配置——环境变量、配置文件、功能标志
- React props 和 context——组件通过 props 接收依赖;context provider 可在测试中替换
- 硬编码的
new或直接调用——每个直接依赖都是接缝可能存在但尚未存在的位置
递进顺序
从优先到最后手段排序。从最显式且可行的选项开始:
- 函数参数注入——将依赖作为参数传入并带生产默认值(显式、类型安全、无需框架)
- 高阶函数——从工厂返回已配置的函数(FP 组合)
- 配置注入——将配置/环境作为参数传入,而不是全局读取
- 模块 mock——
vi.mock()替换导入(仅脚手架——随着覆盖率提升逐步迁移) - 子类覆盖——仅用于遗留类代码(参见
resources/oop-patterns.md)
第 1-3 步只有在持久契约值得时才成为持久设计的候选。否则保持私有或在刻画后移除。第 4-5 步通常是临时脚手架。
快速示例
在刻画 scheduleDelivery 之前,需要为其隐藏依赖引入接缝:
// BEFORE -- 无接缝,不访问真实 API 就无法测试
const scheduleDelivery = (delivery: Delivery): DeliveryPlan => {
const transitDays = fetchTransitDays(delivery.region);
return { ...delivery, totalDays: delivery.preparationDays + transitDays };
};
// AFTER -- 带生产默认值的函数参数接缝
type TransitDaysResolver = (region: string) => number;
const scheduleDelivery = (
delivery: Delivery,
resolveTransitDays: TransitDaysResolver = fetchTransitDays,
): DeliveryPlan => {
const transitDays = resolveTransitDays(delivery.region);
return { ...delivery, totalDays: delivery.preparationDays + transitDays };
};
// 测试 -- 在启用点(参数列表)换入假对象
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() 用作永久架构 |
模块 mock 绕过类型安全并造成隐式耦合。一旦有测试就迁移到参数注入。 |
| 以类模式(子类、DI 容器)为主导 | 在 TypeScript FP 中,函数参数提供自然接缝。类和 DI 容器很少需要。 |
| 什么都 mock 而不是寻找真正接缝 | 只在接缝边界 mock;测试真实逻辑 |
| 创建泄漏实现细节的接缝 | 接缝接口应描述什么,而不是如何 |
| 忘记启用点 | 每个接缝都需要选择行为的位置;没有启用点就不是接缝 |
| 一次打破太多依赖 | 一次打破一个依赖;让一个测试通过;再打破下一个 |