mcpField
F
finding-seams

finding-seams

github.com/citypaul测试与质量760178 次安装4 周前更新
代码仓库

关于 finding-seams

finding-seams 是帮助开发者测试含有隐藏依赖的代码的技能。它讲解如何引入范围最小的替换点,优先采用显式参数注入,并将模块模拟视为临时脚手架。

主要功能

  1. 01识别妨碍测试的隐藏依赖
  2. 02用函数参数建立替换点
  3. 03选择 TypeScript 和 JavaScript 的接缝类型
  4. 04保持生产默认值与调用点不变
  5. 05移除过时的依赖模拟

适用场景

  1. 01测试直接调用外部系统的代码
  2. 02隔离读取全局对象或单例的函数
  3. 03为遗留代码的行为刻画做准备

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/citypaul/.dotfiles --skill finding-seams

安装后在 Claude Code 中输入/finding-seams确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

resources/
creating-seams.md
oop-patterns.md
seam-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)

为现有代码编写测试

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

  1. 列出该函数依赖的所有隐藏依赖——它会创建的协作者、Date.now() / new Date()、process.env、单例。一个不漏,而不只是最让你头疼的那个。
  2. 将每个依赖移到参数列表中,并设置一个能精确复现当前行为的生产默认值——现在 new 和环境变量读取都放在默认值中,因此生产代码的行为不变。完成后,被测函数不会创建任何对象,也不会读取任何全局变量。
  3. 编写测试时,将伪对象作为参数传入。伪对象是你在测试中手写、完全可控的替代物:例如 () => 1_700_000_000_000 这样的字面量,或仅实现接缝狭窄类型所声明方法的小型对象。绝不要通过接缝传入真实协作者——不管它是生产客户端、驱动程序还是服务对象,也不管它的实现有多轻量、本地或基于内存,以及它自己的注释如何声称。如果测试基于真实协作者构建,那么该协作者的形状一旦变化,测试仍会失败;这正是接缝要消除的耦合。如果测试需要 vi.mock()、vi.stubEnv() 或 fake timers 才能运行,说明仍有依赖被隐藏:回到第 2 步,而不是使用这些手段。
  4. 移除现有测试中对该依赖的任何模块模拟——接缝已取代它,同时保留两者意味着旧测试仍然无法看到模拟所隐藏的内容。

交还工作时,请说明你引入的接缝类型,以及它的启用点所在位置(文件和参数默认值、?? 回退或工厂调用所在行),并确认现有调用点没有变化。

如何找到接缝

检查你需要测试的代码中是否有以下内容:

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

方案顺序

按优先级从高到低排列。先从可行方案中最明确的选项开始:

  1. 函数参数注入——将依赖作为参数传入,并提供生产默认值(显式、类型安全,无需框架)
  2. 高阶函数——通过工厂返回已配置的函数(FP 组合)
  3. 配置注入——将配置或环境作为参数传入,而不是全局读取
  4. 模块模拟——使用 vi.mock() 替换导入(仅用于搭建——随着测试覆盖率提升,逐步迁移掉)
  5. 继承并重写——仅用于遗留类代码(参见 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 容器。
不寻找真正的接缝,反而模拟一切只在接缝边界进行模拟;测试真实逻辑
创建泄露实现细节的接缝接缝接口应描述做什么,而非如何做
忘记启用点每个接缝都需要一个选择行为的位置;如果没有启用点,它就不是接缝
一次拆分太多依赖一次拆分一个依赖;先让测试通过,再拆分下一个

评论

支持 Markdown