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 整个编辑器运行时。
三、面向可测试性的接口设计
文档把可测试性下沉到接口设计层面,给出三条准则:
接受依赖,不要创建依赖
// Testable function processOrder(order, paymentGateway) {} // Hard to test function processOrder(order) { const gateway = new StripeGateway(); }返回结果,不要制造副作用
// Testable function calculateDiscount(cart): Discount {} // Hard to test function applyDiscount(cart): void { cart.total -= discount; }小表面(small surface area)——方法越少 = 需要的测试越少;参数越简单 = 测试设置越简单。
文档在这里引出了"深模块"(deep modules)概念,出自A Philosophy of Software Design:小接口 + 大量实现。设计时的自问是:能否减少方法数?能否简化参数?能否把更多复杂性藏到内部?
Plate 各包的插件契约正是这一思想的体现:以 packages/core 为例,插件通过configure()暴露一个小的配置面,而options、api、node等能力被封装在内部。类型契约测试(下一节)只需要断言这个公共面的形状,无需触达实现。
四、反模式:水平切片(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 工具集
文档要求先搜索是否存在导出Expect与Equal的文件;若没有则创建:
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的宽化匹配);Not、IsAny、IsNever—— 边界情形守护(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.ts、plate-editor-value-contracts.ts、plate-plugin-contracts.ts、plugin-composition-contracts.ts、slate-plugin-contracts.ts五个契约文件,覆盖编辑器配置、插件组合、值类型等泛型 API 的类型契约; - packages/slate/type-tests:包含
create-editor.ts、history.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;对照文档可以看到几个精确的对应关系:
IsAny<T> = 0 extends 1 & T ? true : false与文档工具集逐字一致——这是识别"泛型推导坍缩为any"这一最危险回归的标准手法;- 文件里用
type _name = ...前缀下划线 + 类型别名的形式做纯编译期断言,正是文档 Tips 中"需要类型层断言时用type _name = Expect<...>"的变体(这里用AssertFalse<T extends false>承担Expect的角色); - 断言对象全部是公共接口面:
TablePlugin本身、configure回调里的ctx、ctx.editor、getApi(...)返回的 API 形状——没有一个断言触达内部实现,完整贯彻了"测试公共接口、能经受内部重构"的第一节哲学。
这也解释了为什么根 package.json 中check脚本把typecheck与test: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),仅供参考