ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

Plate 仓库中的测试驱动开发实践:红绿重构循环、垂直切片与类型级测试 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 修复也是 TDDTDD 同样适用于修 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 ExpectT extends true() {} export type EqualX, Y (T() T extends X ? 1 : 2) extends T () T extends Y ? 1 : 2 ? true : false; export type NotT extends boolean T extends true ? false : true; export type IsAnyT 0 extends 1 T ? true : false; export type IsNeverT [T] extends [never] ? true : false;各工具的职责ExpectT extends true—— 编译报错即测试失败EqualX, Y—— 精确类型相等能挫败any的宽化匹配Not、IsAny、IsNever—— 边界情形守护any/never会破坏朴素的类型比较。7.2 正向断言import { Expect, Equal, Not, IsAny } from ./utils; // 每个测试用块级作用域包裹避免命名冲突 { type Result ReturnTypetypeof myGenericFnSomeInput; ExpectEqualResult, { id: string; name: string }; ExpectNotIsAnyResult; }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 AssertFalseT extends false T; type IsAnyT 0 extends 1 T ? true : false; type _tablePluginNotAny AssertFalseIsAnytypeof TablePlugin; const configuredTablePlugin TablePlugin.configure((ctx) { type _ctxNotAny AssertFalseIsAnytypeof ctx; type _editorNotAny AssertFalseIsAnytypeof ctx.editor; const isSelectingCell: boolean ctx.editor .getApi(TablePlugin) .table.isSelectingCell(); void isSelectingCell; return { options: { disableMerge: true, }, }; }); void configuredTablePlugin;对照文档可以看到几个精确的对应关系IsAnyT 0 extends 1 T ? true : false与文档工具集逐字一致——这是识别泛型推导坍缩为any这一最危险回归的标准手法文件里用type _name ...前缀下划线 类型别名的形式做纯编译期断言正是文档 Tips 中需要类型层断言时用type _name Expect...的变体这里用AssertFalseT 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 testLint 兼容纯类型文件需抑制未使用变量告警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),仅供参考
返回列表