SuperAPI AI 工具集

finding-seams

测试与质量

查看项目

更新于 10-16 17:54

技能介绍

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)

将既有代码置于测试之下

在编写任何测试之前,先完成以下步骤:

  1. 列出函数访问的每一个隐藏依赖——它构造的协作者、Date.now() / new Date()、process.env、单例。全部列出,而不只是最烦人的那个。
  2. 将每个依赖移到参数列表,并带一个完全复现当前行为的生产默认值——new 和环境读取现在位于默认值中,因此生产行为不变。完成后,被测函数不构造任何东西,也不读取任何全局。
  3. 通过将假对象作为参数传入来编写测试。假对象是你在测试中手写并完全控制的替身:如 () => 1_700_000_000_000 这样的字面量,或只实现接缝窄类型所命名方法的小对象。绝不要通过接缝传入真实协作者——无论是生产客户端、驱动还是服务对象,无论其实现多么廉价、本地或内存中,也无论其注释如何声称。基于真实协作者构建的测试仍会在该协作者形状变化时失败,而这正是接缝要切断的耦合。如果测试需要 vi.mock()、vi.stubEnv() 或假定时器才能运行,说明仍有依赖被隐藏:回到第 2 步,而不是使用它们。
  4. 移除现有测试中该依赖的任何模块 mock——接缝取代了它,两者都留意味着旧测试仍看不到 mock 所隐藏的内容。

交回工作时,说明你引入的接缝类型及其启用点位置(参数默认值的文件与行号、?? 回退或工厂调用),并确认现有调用点未变。

如何寻找接缝

在需要测试的代码中寻找以下内容:

  1. 函数参数——任何可以接受不同实现的参数
  2. 默认参数值——(resolve = fetchFromApi) 已经是一个接缝
  3. 模块导入——任何导入都可能被 mock(但优先参数注入)
  4. 配置——环境变量、配置文件、功能标志
  5. React props 和 context——组件通过 props 接收依赖;context provider 可在测试中替换
  6. 硬编码的 new 或直接调用——每个直接依赖都是接缝可能存在但尚未存在的位置

递进顺序

从优先到最后手段排序。从最显式且可行的选项开始:

  1. 函数参数注入——将依赖作为参数传入并带生产默认值(显式、类型安全、无需框架)
  2. 高阶函数——从工厂返回已配置的函数(FP 组合)
  3. 配置注入——将配置/环境作为参数传入,而不是全局读取
  4. 模块 mock——vi.mock() 替换导入(仅脚手架——随着覆盖率提升逐步迁移)
  5. 子类覆盖——仅用于遗留类代码(参见 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;测试真实逻辑
创建泄漏实现细节的接缝 接缝接口应描述什么,而不是如何
忘记启用点 每个接缝都需要选择行为的位置;没有启用点就不是接缝
一次打破太多依赖 一次打破一个依赖;让一个测试通过;再打破下一个