Plate 仓库中的测试驱动开发实践:红绿重构循环、垂直切片与类型级测试
2026/9/14 12:48:19 网站建设 项目流程

Plate 仓库中的测试驱动开发实践:红绿重构循环、垂直切片与类型级测试

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

本文基于 Plate(基于 Slate 的富文本编辑器框架,支持 AI 与 shadcn/ui)仓库中的 TDD 技能文档展开。文章完整继承原文档的红绿重构(Red-Green-Refactor)方法论:行为优先的测试哲学、系统边界处的 Mock 策略、面向可测试性的接口设计、垂直切片反模式,以及 TypeScript 编译期类型断言(Type Testing)工具集;并结合本仓库的bun test测试脚本、typecheck管线与packages/*/type-tests目录下的真实类型契约文件,说明该方法论在大型 TypeScript monorepo 中的落地形态。

一、测试哲学:验证行为,而非实现细节

TDD 文档开宗明义地给出核心原则:测试应通过公共接口验证行为,而不是绑定实现细节。代码可以完全重写,测试不应随之改变。

好的测试是集成式的:它们通过公共 API 走真实的代码路径,描述系统"做什么"(what),而不是"怎么做"(how)。一个合格的测试读起来就像一份规格说明——"user can checkout with valid cart" 直接告诉你系统具备什么能力。正因为不关心内部结构,这类测试才能在内部门面反复重构后依然存活。

文档给出的正例:

// GOOD: Tests observable behavior test("user can checkout with valid cart", async () => { const cart = createCart(); cart.add(product); const result = await checkout(cart, paymentMethod); expect(result.status).toBe("confirmed"); }); // GOOD: Verifies through interface test("createUser makes user retrievable", async () => { const user = await createUser({ name: "Alice" }); const retrieved = await getUser(user.id); expect(retrieved.name).toBe("Alice"); });

好测试的五个特征:

  • 测试用户/调用方关心的行为;
  • 只使用公共 API;
  • 能经受内部重构;
  • 描述 WHAT 而非 HOW;
  • 每个测试只承载一个逻辑断言。

反例则暴露了与实现耦合的测试:mock 内部协作者、测试私有方法,或通过旁路手段(例如绕过接口直接查数据库)来"验证"。危险的信号是:你重构了代码,测试却挂了,但行为并没有变。

// BAD: Tests implementation details test("checkout calls paymentService.process", async () => { const mockPayment = jest.mock(paymentService); await checkout(cart, payment); expect(mockPayment.process).toHaveBeenCalledWith(cart.total); }); // BAD: Bypasses interface to verify test("createUser saves to database", async () => { await createUser({ name: "Alice" }); const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]); expect(row).toBeDefined(); });

红旗清单(出现任何一条都应警惕):

  • mock 内部协作者;
  • 测试私有方法;
  • 断言调用次数/调用顺序;
  • 无行为变化的重构导致测试失败;
  • 测试名描述的是 HOW 而不是 WHAT。

文档还给出一个重要建议:优先在实现之前写测试。如果代码已经写完了,考虑从测试重新开始而不是"补测试"——事后写的测试倾向于验证"你实际构建的东西",而不是"需求要求的东西"。

在 Plate 仓库中的对应形态

Plate 是一个以packages/下数十个功能包(core、slate、table、markdown、list 等)组成的 monorepo,每个包都有自己独立的测试与类型检查。从 根 package.json 可以看到验证管线的组织方式:

{ "check": "pnpm lint && pnpm typecheck && pnpm test:all && pnpm test:slowest", "p:test": "cd ${INIT_CWD:-.} && bun test", "p:typecheck": "tsc -p ./tsconfig.json", "typecheck": "pnpm g:typecheck" }

其中p:test说明每个包内部使用bun test作为运行时测试执行器;而p:typecheck通过tsc做编译期检查——这正是后文"类型级测试"的运行载体。运行时测试与编译期断言两条轨道并行,恰好印证了文档"测试行为 + 测试类型契约"的双轨思路。

二、Mock 策略:只在系统边界处打桩

文档对 Mock 的立场非常克制:只在系统边界(system boundaries)处 mock

  • 外部 API(支付、邮件等);
  • 数据库(有时——更推荐测试专用数据库);
  • 时间 / 随机性;
  • 文件系统(有时)。

不要 mock:你自己的类/模块、内部协作者、任何你能控制的东西。

支撑这一策略的第二个工具是依赖注入(Dependency Injection)——把外部依赖作为参数传入,而不是在函数内部创建:

// Easy to mock function processPayment(order, paymentClient) { return paymentClient.charge(order.total); } // Hard to mock function processPayment(order) { const client = new StripeClient(process.env.STRIPE_KEY); return client.charge(order.total); }

第三个工具是SDK 风格接口——为每个外部操作提供独立的函数,而不是一个万能fetch

// GOOD: Each function is independently mockable const api = { getUser: (id) => fetch(`/users/${id}`), getOrders: (userId) => fetch(`/users/${userId}/orders`), createOrder: (data) => fetch("/orders", { method: "POST", body: data }), }; // BAD: Mocking requires conditional logic inside the mock const api = { fetch: (endpoint, options) => fetch(endpoint, options), };

对比 Plate 仓库可以看到这一原则在真实工程中的影子:各功能包普遍采用plugin + 独立 API 函数的组织方式,例如表格包的TablePlugin.configure()ctx.editor.getApi(TablePlugin)这种"按操作取 API"的调用面(见 table-plugin-contracts.ts)。这种把每个能力暴露为独立可访问函数的 SDK 风格表面,正是文档所推崇的"每个函数可独立 mock/独立断言"的接口形态——从源码结构看,它让测试可以只关注单个操作的行为契约,而不必 mock 整个编辑器运行时。

三、面向可测试性的接口设计

文档把可测试性下沉到接口设计层面,给出三条准则:

  1. 接受依赖,不要创建依赖

    // Testable function processOrder(order, paymentGateway) {} // Hard to test function processOrder(order) { const gateway = new StripeGateway(); }
  2. 返回结果,不要制造副作用

    // Testable function calculateDiscount(cart): Discount {} // Hard to test function applyDiscount(cart): void { cart.total -= discount; }
  3. 小表面(small surface area)——方法越少 = 需要的测试越少;参数越简单 = 测试设置越简单。

文档在这里引出了"深模块"(deep modules)概念,出自A Philosophy of Software Design小接口 + 大量实现。设计时的自问是:能否减少方法数?能否简化参数?能否把更多复杂性藏到内部?

Plate 各包的插件契约正是这一思想的体现:以 packages/core 为例,插件通过configure()暴露一个小的配置面,而optionsapinode等能力被封装在内部。类型契约测试(下一节)只需要断言这个公共面的形状,无需触达实现。

四、反模式:水平切片(Horizontal Slices)

文档用专门章节警告一个高频误用:不要"先写完所有测试,再写所有实现"。这就是"水平切片"——把 RED 阶段理解成"写完全部测试",把 GREEN 阶段理解成"写完全部代码"。

它生产"垃圾测试"(crap tests)的原因:

  • 批量写出的测试针对的是想象的行为,而非实际的行为
  • 最终测试的是事物的"形状"(数据结构、函数签名),而不是用户可见行为;
  • 测试对真实变化变得不敏感——行为坏了测试照样过,行为正常测试反而挂;
  • 你跑在了头灯前面(outrun your headlights),在理解实现之前就锁死了测试结构。

正确做法是垂直切片 + 曳光弹(tracer bullets):一个测试 → 一个实现 → 循环,每个测试都回应上一轮学到的东西:

WRONG (horizontal): RED: test1, test2, test3, test4, test5 GREEN: impl1, impl2, impl3, impl4, impl5 RIGHT (vertical): RED→GREEN: test1→impl1 RED→GREEN: test2→impl2 RED→GREEN: test3→impl3 ...

五、工作流:规划、曳光弹、增量循环与重构

5.1 规划(Planning)

写任何代码之前完成以下确认:

  • 与用户确认需要哪些接口变更;
  • 与用户确认测试哪些行为(并排优先级);
  • 识别"深模块"机会(小接口、深实现);
  • 面向可测试性设计接口;
  • 列出待测行为清单(注意:是行为,不是实现步骤);
  • 获得用户对计划的批准。

关键提问是:"公共接口应该长什么样?哪些行为最值得测试?"

文档特别强调:你不可能测试一切。必须和用户确认哪些行为最关键,把测试精力集中在关键路径和复杂逻辑上,而不是穷举所有边界情况。

5.2 曳光弹(Tracer Bullet)

一个测试,只确认系统的一件事

RED: 写测试 → 跑测试 → 确认它以正确的方式失败 GREEN: 写最小实现 → 跑测试 → 确认它通过

两条边界规则:

  • 测试立刻通过?说明你在测已有行为——修正测试;
  • 测试是 error(报错)而不是 assertion failure(断言失败)?先修掉报错——"报错"和"失败"不是一回事。

这个测试就是曳光弹:它证明端到端路径是通的。

5.3 增量循环(Incremental Loop)

对剩余每一个行为重复:

RED: 写下一个测试 → 跑测试 → 确认它以正确的方式失败 GREEN: 写最小实现 → 跑测试 → 确认它通过

循环规则:

  • 一次一个测试;
  • 只写能让当前测试通过的代码量;
  • 不要为未来的测试做预判;
  • 测试始终聚焦可观察行为。

5.4 重构(Refactor)

所有测试通过后寻找重构候选:

  • 提取重复;
  • 加深模块(把复杂性挪到简单接口后面);
  • 在自然的地方应用 SOLID;
  • 思考新代码揭示了旧代码的什么问题;
  • 每一步重构后都跑测试

常见的"症状 → 手法"映射:重复 → 提取函数/类;长方法 → 拆成私有辅助函数;浅模块 → 合并或加深;特性依恋(feature envy)→ 把逻辑挪到数据所在处;基本类型偏执(primitive obsession)→ 引入值对象。

一条铁律:绝不要在 RED 状态下重构。先回到 GREEN。

5.5 每轮检查清单

[ ] 测试描述的是行为,不是实现 [ ] 测试只用公共接口 [ ] 测试能经受内部重构 [ ] 代码对这个测试而言是最小的 [ ] 没有加入投机性功能 [ ] 写代码前亲眼看过测试失败 [ ] 失败原因是预期的(功能缺失,而不是拼写错误) [ ] 其他所有测试仍然通过

六、Bug 修复也是 TDD

TDD 同样适用于修 bug——先写一个能复现 bug 的测试。文档给出的示例:

# Bug: empty email passes validation RED: test("rejects empty email", () => { const result = validateEmail(""); expect(result.valid).toBe(false); }); → 跑测试 → FAILS(空字符串通过了校验)✓ GREEN: 补上检查: if (!email || !email.includes("@")) return { valid: false } → 跑测试 → PASSES ✓ 同时验证其他所有校验测试仍然通过。

在 Plate 这类编辑器仓库中,这类模式对应着"先写复现用例再修"的常规流程:运行时行为问题(如选区、光标、粘贴)用bun test跑包内测试复现,而类型契约问题(泛型被推导成了any、配置返回值类型丢失)则由下一节的编译期断言捕获。

七、类型级测试(Type Testing):编译期断言

文档的后半部分是针对 TypeScript 项目的独特贡献:类型测试是编译期的断言,没有运行时——只需bun typecheck(本仓库等价于tsc检查)。它能捕获运行时测试看不见的回归:泛型推导、条件类型、类型约束。

适用场景:泛型 API、工具类型、复杂推导、映射/条件类型、确保非法用法会报错。不适用:诸如"string 属性接受 string"这类琐碎断言。

7.1 工具集

文档要求先搜索是否存在导出ExpectEqual的文件;若没有则创建:

export function Expect<T extends true>() {} export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends < T >() => T extends Y ? 1 : 2 ? true : false; export type Not<T extends boolean> = T extends true ? false : true; export type IsAny<T> = 0 extends 1 & T ? true : false; export type IsNever<T> = [T] extends [never] ? true : false;

各工具的职责:

  • Expect<T extends true>—— 编译报错即测试失败;
  • Equal<X, Y>—— 精确类型相等(能挫败any的宽化匹配);
  • NotIsAnyIsNever—— 边界情形守护(any/never会破坏朴素的类型比较)。

7.2 正向断言

import { Expect, Equal, Not, IsAny } from "./utils"; // 每个测试用块级作用域包裹,避免命名冲突 { type Result = ReturnType<typeof myGenericFn<SomeInput>>; Expect<Equal<Result, { id: string; name: string }>>; Expect<Not<IsAny<Result>>>; }

7.3 负向测试:@ts-expect-error的位置纪律

@ts-expect-error必须紧贴在报错行的上一行,并且必须写明原因。若指令未被使用(即预期中的约束缺失),它就是失败——这正是"负向测试"的精髓:没有错误发生本身就是回归。

// ✅ 指令紧贴报错行 doSomething({ // @ts-expect-error - name must be string name: 123, }); // ❌ 指令离报错太远 doSomething({ // @ts-expect-error - name must be string ...defaults, name: 123, });

7.4 实用技巧

  • declare const声明无运行时开销的 mock 值:declare const ctx: SomeCtx;
  • 需要纯类型层断言时用type _name = Expect<...>(不需要运行时调用Expect());
  • 纯类型文件顶部加/* biome-ignore-all lint */抑制未使用变量告警(Plate 仓库的 lint 管线正是 Biome,见 biome.jsonc 与根脚本"lint": "biome check . && eslint")。

验证命令:bun typecheck(本仓库等价路径是pnpm p:typecheck,即tsc -p ./tsconfig.json)。能编译,即通过。

7.5 仓库实证:packages/*/type-tests目录

这一方法论在 Plate 仓库中有直接的落地证据。仓库在多个核心包下维护了独立的type-tests/目录:

  • packages/core/type-tests:包含editor-configure-contracts.tsplate-editor-value-contracts.tsplate-plugin-contracts.tsplugin-composition-contracts.tsslate-plugin-contracts.ts五个契约文件,覆盖编辑器配置、插件组合、值类型等泛型 API 的类型契约;
  • packages/slate/type-tests:包含create-editor.tshistory.ts,对createEditor与历史(undo/redo)扩展的类型推导做断言;
  • packages/table/type-tests/table-plugin-contracts.ts:对表格插件的公共类型面做契约断言。

table-plugin-contracts.ts的实际内容为例:

import { TablePlugin } from '@platejs/table/react'; type AssertFalse<T extends false> = T; type IsAny<T> = 0 extends 1 & T ? true : false; type _tablePluginNotAny = AssertFalse<IsAny<typeof TablePlugin>>; const configuredTablePlugin = TablePlugin.configure((ctx) => { type _ctxNotAny = AssertFalse<IsAny<typeof ctx>>; type _editorNotAny = AssertFalse<IsAny<typeof ctx.editor>>; const isSelectingCell: boolean = ctx.editor .getApi(TablePlugin) .table.isSelectingCell(); void isSelectingCell; return { options: { disableMerge: true, }, }; }); void configuredTablePlugin;

对照文档可以看到几个精确的对应关系:

  1. IsAny<T> = 0 extends 1 & T ? true : false与文档工具集逐字一致——这是识别"泛型推导坍缩为any"这一最危险回归的标准手法;
  2. 文件里用type _name = ...前缀下划线 + 类型别名的形式做纯编译期断言,正是文档 Tips 中"需要类型层断言时用type _name = Expect<...>"的变体(这里用AssertFalse<T extends false>承担Expect的角色);
  3. 断言对象全部是公共接口面TablePlugin本身、configure回调里的ctxctx.editorgetApi(...)返回的 API 形状——没有一个断言触达内部实现,完整贯彻了"测试公共接口、能经受内部重构"的第一节哲学。

这也解释了为什么根 package.json 中check脚本把typechecktest:all并列为一等验证步骤:在这个仓库里,类型契约与运行时行为享有同级的"必须通过"地位。

八、方法论落点小结

环节文档要求本仓库对应
运行时测试bun test,行为优先,只走公共 API根脚本"p:test": "cd ${INIT_CWD:-.} && bun test"
类型契约测试Expect/Equal/IsAny工具集 +@ts-expect-error纪律packages/core/type-tests、packages/slate/type-tests、packages/table/type-tests
验证管线能编译即通过"p:typecheck": "tsc -p ./tsconfig.json""check"串联 lint + typecheck + test
Lint 兼容纯类型文件需抑制未使用变量告警Biome 管线,biome check .(biome.jsonc)

需要说明的适用前提:本文引用的验证脚本与类型测试目录均以当前仓库 package.json 与各包目录的实际内容为准;文档中的bun typecheck表述在 Plate 仓库中等价于tsc项目检查。

这套方法的可迁移内核可以浓缩为三句话:用公共接口写行为测试,用垂直切片小步推进,用编译期断言守住类型契约。对任何以 TypeScript 泛型 API 为核心资产的项目——无论是 Plate 这样的编辑器框架,还是其他泛型密集型库——这三个环节构成了一条完整的"红绿重构 + 类型守卫"闭环。

【免费下载链接】plateRich-text editor with AI and shadcn/ui项目地址: https://gitcode.com/GitHub_Trending/pl/plate

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询