Penpot monorepo 测试工程实践:从 TDD 纪律到跨模块测试执行规范
【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot
Penpot(开源的设计协作与 UI/UX 平台)采用多模块 monorepo 结构:common共享 CLJC 逻辑、frontend浏览器端、backend服务端、exporter渲染导出等模块使用截然不同的测试技术栈与运行入口。本文以仓库内的测试工程文档(.serena/memories/testing.md)为骨架,结合各模块的测试源码、runner 与包脚本,系统讲解 Penpot 贡献者应当遵循的测试价值观、TDD 与 Bug 复现流程、模块化执行命令以及一套可落地的验证清单。读完你能掌握在这套代码库中"何时写测试、如何写测试、跑哪条命令、怎样阅读结果"的完整方法。
1. 概述:测试是代码能工作的证明
Penpot 工程文档将测试视为"代码能工作的证明"(Tests are proof that code works),任何行为变更都要求有对应测试。由于这是一个横跨多种语言运行时(JVM Clojure、浏览器 CLJS、Node.js、Rust/WASM)的多模块仓库,不同模块拥有各自的测试命令、辅助函数、runner 注册要求与约定。以各模块实际布局为证:
common:CLJC 共享逻辑,单测运行在 JVM 与 JS 双端,测试位于 common/test/common_tests;frontend:CLJS + React/Rumext 前端,单测在 frontend/test/frontend_tests,另有 frontend/playwright 下的 Playwright E2E;backend:JVMclojure.test测试位于 backend/test/backend_tests,由 Kaocha 编排(见 backend/tests.edn);exporter:Node 渲染/导出服务,测试位于 exporter/test/exporter_tests。
本文沉淀的是跨模块通用的测试原则与执行纪律;各模块细节分别见 .serena/memories/common/testing.md、.serena/memories/frontend/testing.md 与后端相关记忆文档(.serena/memories/backend/core.md)。
2. 何时该写测试,何时不需要
需要为以下改动补测试:
- 实现新的逻辑或行为(new logic or behavior);
- 修复任何 Bug——必须附带可复现的回归测试(reproduction test);
- 修改既有功能;
- 增加边界情况(edge case)处理。
明确不需要测试的场景:纯配置变更、文档更新、无行为影响的静态内容改动。
3. 推荐工作流:TDD 的 RED → GREEN → REFACTOR
工程文档推荐严格的红绿重构循环:在写实现代码之前,先写一个会失败的测试;对 Bug 修复而言,先用测试复现 Bug,再动手修复。
RED GREEN REFACTOR Write a test Write minimal code Clean up the that fails ──→ to make it pass ──→ implementation ──→ (repeat) │ │ │ ▼ ▼ ▼ Test FAILS Test PASSES Tests still PASS三个阶段的行为准则:
- RED——先写测试:测试必须失败。一上来就通过的测试证明不了任何东西(A test that passes immediately proves nothing);
- GREEN——写最小代码让测试通过:不要过度设计(don't over-engineer);
- REFACTOR——在绿灯前提下重构:提取共享逻辑、改善命名、消除重复,但不得改变行为;每走一步都要重跑测试。
当 TDD 确实不可行时(探索性开发、与未知 API 强耦合等),仍然必须在把工作视为完成之前补上测试。
4. Bug 修复的 Prove-It 模式:先复现,后修复
接到 Bug 报告时,不要一开始就尝试修复。文档要求先写出能复现该 Bug 的测试,按以下五步执行:
- 写一个能证明该 Bug 存在的测试;
- 确认该测试失败(证明 Bug 确实存在,且测试真的打到了问题);
- 实现修复;
- 确认该测试通过(证明修复确实生效);
- 跑该模块的完整测试套件,确认无回归。
这套"测试驱动修复"流程在仓库中大量使用:例如 common/test/common_tests/logic/variants-switch-test.cljc 这类针对组件/变体切换逻辑的测试命名空间,其测试用例全部以"给定某状态 → 执行某行为 → 断言某结果"的方式组织,天然适合先写失败用例再补实现。
5. 核心测试原则
| 原则 | 含义 |
|---|---|
| Test State, Not Interactions | 断言结果/状态(outcomes),而非断言方法调用;这样重构时测试不会碎 |
| DAMP over DRY | 测试是规格说明(specifications),允许重复;只要每个测试自包含、可读,能"完整讲一个故事",就不必强行抽公共 helper |
| Prefer Real Implementations | 优先级 Real > Fake > Stub > Mock;只在边界(网络、RPC、文件系统、邮件)处使用 Mock |
| Arrange-Act-Assert | 每个测试都必须三明治式组织:准备 / 动作 / 验证 |
| One Assertion Per Concept | 一个测试只验证一个行为;复合断言要拆分 |
| Descriptive Test Names | 测试名要像规格一样可读,能描述所验证的行为 |
6. 优先使用真实实现,而非 Mock
按以下优先级递减工作:
- Real implementation(真实实现)——用真实的协作者测真实代码,置信度最高;
- Fake(替身/仿制品)——一个简化但可用的内存实现(例如用 atom/dict 支撑的 store 替代真实数据库);
- Stub(桩)——返回固定数据;适用于协作者逻辑与本测试无关的场景;
- Mock(模拟对象)——最后手段,只用于边界,且仅当需要验证与某个无法 fake 的外部系统的交互时才用。
经验法则:能写 fake 或直接用真实实现,就那样做。如果你发现自己正在断言"调用次数"或"调用顺序",请停下来问一句:换成 fake 是否会更清晰?在 Penpot 的 common 测试里,这条原则体现为大量基于"真实文件结构 + 变更流"的测试——通过 common/src/app/common/test_helpers/files.cljc 等 helpers 构造真实># CORRECT(正确): pnpm run test 2>&1 > /tmp/test-output.txt grep -A 5 "failures" /tmp/test-output.txt # WRONG(错误,可能隐藏失败): pnpm run test 2>&1 | tail -20 pnpm run test 2>&1 | grep "failures"
- 想缩小范围应使用
--focus参数,而不是过滤输出; - 完整阅读输出文件,才能彻底理解测试结果。
11.2 JS/CLJS 端测试(frontend、common、exporter)
- 日常运行优先
pnpm run test:quiet:它会先静默构建测试 bundle,再运行 runner,输出干净(实现见 common/scripts/test-quiet.js 与 frontend/scripts/test-quiet.js,内部依次执行build:test与node target/tests/test.js); - 想看构建与测试混排的完整输出,用
pnpm run test(总是先构建再运行,对应 common/package.json 的"test": "pnpm run build:test && node target/tests/test.js"); - 首次
build:test之后,可直接复用编译产物加速迭代:node target/tests/test.js [--focus ...] [--log-level ...]。构建产物与 shadow-cljs:testtarget 的对应关系见 common/shadow-cljs.edn(output-dir "target/tests"、init-fn common-tests.runner/-main)。
runner 本身支持聚焦与日志级别控制,CLI 解析可在 common/test/common_tests/runner.cljc 看到:--focus(或-f)接收一个测试命名空间,或namespace/test-var;--log-level(或-l)取值trace|debug|info|warn|error。frontend runner frontend/test/frontend_tests/runner.cljs 与之一致。
11.3 common 模块(JVM + JS 双端)
common 是 CLJC 代码,单测需覆盖相关运行时:后端/共享逻辑跑 JVM,前端/exporter 行为跑 JS。涉及几何、组件与文件模型时 JVM 测试通常又快又合适,但当改动涉及 WASM 修饰符数学或 CLJS 特有状态时,JS/浏览器行为可能不同。
从 common/ 目录执行:
# JVM 全量 clojure -M:dev:test # JS 全量(先构建、压静输出) pnpm run test:quiet # JS 全量(构建输出可见) pnpm run test # 聚焦 JVM 测试命名空间 clojure -M:dev:test --focus common-tests.logic.variants-switch-test # 聚焦 JVM 单个测试 var clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch # 聚焦 JS 测试命名空间 / 单个 var pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test/test-sync-when-changing-attribute # 压静日志 pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test --log-level warn # 只构建不运行 pnpm run build:test # 构建后直接跑编译好的 runner node target/tests/test.js [--focus ...] [--log-level ...]多个 JVM--focus标志以并集方式组合。注册规则:新的 common JS 测试命名空间必须被 require/列出到 common/test/common_tests/runner.cljc(可在其中看到数百个测试命名空间的显式注册列表);已有命名空间里新增测试 var 则无需改 runner。
驱动生产路径而非手拼状态:对 shape 变更,优先使用cls/generate-update-shapes配合thf/apply-changes这类生产路径 helper;带 keep-touched 行为的组件替换用tho/swap-component-in-shape并传{:keep-touched? true}。thf/apply-changes默认开启校验,通常会给出最有用的不变量(invariant)失败信息;仅当故意构造"合法但中间态畸形"的数据时才传:validate? false。几何敏感测试在摆放 shape 前还应先参考几何不变量文档(.serena/memories/common/geometry-invariants.md),用几何保持型 helper 或生产变更 helper,而非直接单字段编辑。
11.4 frontend 模块(CLJS 单测 + Playwright E2E + 浏览器实时验证)
frontend 单测使用cljs.test,应保持确定性,尽量不依赖 DOM/UI 集成,并 mock 掉副作用(RPC、存储、定时器、网络)。从 frontend/ 执行:
# 单测全量(静默构建) pnpm run test:quiet # 单测全量(输出可见) pnpm run test # 聚焦命名空间 / var pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens pnpm run test:quiet -- --focus frontend-tests.logic.components-and-tokens/change-spacing-token-in-main-updates-copy-layout # 附加 --log-level warn 压静 app.* 日志(支持 trace|debug|info|warn|error) # 只构建测试 pnpm run build:test # 构建后直接运行编译产物 node target/tests/test.js [--focus ...] [--log-level ...] # 监听模式 pnpm run watch:testPlaywright 集成测试:除非被明确要求,否则不要新增、修改或运行 frontend/playwright 下的集成测试;确需运行时,在 frontend/ 执行pnpm run test:e2e或用pnpm run test:e2e --grep "pattern"做模式过滤,并在环境未就绪时先通过./scripts/setup安装依赖。这类集成测试通过拦截网络/WebSocket 流量来模拟后端行为,因此页面需要的每个 RPC 与 WebSocket 都必须 mock:BasePage.mockRPC会自动加/api/rpc/command/前缀,只需传get-profile这类命令名;涉及 workspace 等 WebSocket 页面应继承/使用BaseWebSocketPage,在每测试前初始化 ws mock,并用现成 helpers mock/ws/notifications。定位器(locator)按用户语义选择:getByRole→getByLabel→getByPlaceholder→getByText→ alt/title 等语义替代,最后才是getByTestId。测试命名从用户视角出发,优先使用正向、单一目的的断言。
浏览器实时验证:因为 CLJC 同时编译到 JVM 与 CLJS,JVM/common 测试可能漏掉仅存在于浏览器的状态问题(运行时差异、WASM 修饰符数学、真实指针事件)。需要时借助 .serena/memories/frontend/cljs-repl.md 检查线上 app 状态,.serena/memories/frontend/playwright-gestures.md 处理需要真实输入的场景;构建/热重载问题见 .serena/memories/frontend/compile-diagnostics.md,运行时崩溃见 .serena/memories/frontend/handling-crashes.md。翻译.po文件改动会打进index.html,需刷新浏览器才能看到。
11.5 backend 模块(JVM 测试 + e2e)
后端测试为 JVMclojure.test,测试位于 backend/test/backend_tests(如rpc_team_test.clj、rpc_profile_test.clj、media_test.clj等命名可看出覆盖 RPC、媒体、认证等业务域),由 Kaocha 编排——backend/tests.edn 定义了:unit测试套件(test-paths 为test/src,ns 模式匹配.*-test$),backend/deps.edn 的:testalias 指向-m kaocha.runner、:devalias 把test目录加入 extra-paths。因此后端直接使用clojure -M:dev:test(无需 pnpm 包装),且同样适用"先落盘再读输出"的文件管道规则。聚焦方式与 common 的 JVM 用法一致(--focus <ns>/--focus <ns>/<var>,多 flag 并集)。
另外仓库根目录 backend/test/e2e 下还有一批.mjs端到端测试(如auth-flow.test.mjs、export-binfile.test.mjs),属于后端 HTTP 流程的自动化验证,可结合各模块文档按需使用。
11.6 exporter 模块
从 exporter/ 执行:
pnpm run build:test # 只构建 Node 测试 bundle pnpm run test # 构建并运行,完整输出 pnpm run test:quiet # 构建并运行,压静构建输出 node target/tests/test.js --focus exporter-tests.renderer-svg-test # 复用已编译 bundle 聚焦命名空间 node target/tests/test.js --focus exporter-tests.renderer-svg-test/creates-the-correct-gradient-element # 聚焦单个 var node target/tests/test.js --log-level warn # 设定日志级别 pnpm run check-fmt:clj # 格式检查 pnpm run lint:clj # ClojureScript lint注意 exporter 的test:quiet会接受转发参数但总是重建 bundle,聚焦运行更推荐build:test后直接调用 runner。所有 exporter 测试命名空间必须注册到exporter-tests.runner。
12. 注册新测试命名空间:改完代码别忘这一步
三个 CLJS runner 都要求新命名空间显式注册(新 var 不需要):
- common:编辑 common/test/common_tests/runner.cljc 的 require 列表;
- frontend:编辑 frontend/test/frontend_tests/runner.cljs 的 require 列表;
- exporter:注册到
exporter-tests.runner。
漏注册会导致新测试被静默跳过、套件却显示全绿——这是最危险的一种"假通过"。
13. 实现完成后的验证清单
完成任何实现后,逐项核对:
- 每个新行为都有对应测试
- 所触碰模块的全部测试通过
- Bug 修复包含"修复前确实失败"的复现测试
- 测试名能描述被验证的行为
- 没有测试被跳过或禁用
- 所触碰模块的 lint/formatter 通过
- 新测试文件已注册进对应模块的 runner/入口(见第 12 节)
结语
Penpot monorepo 的测试文化可以浓缩为三句话:先写会失败的测试(TDD 与 Prove-It),用真实实现与 fixture 把状态管好(Real > Fake > Stub > Mock),以及带着纪律运行测试(不滤输出、先落盘、用--focus缩范围、新命名空间必注册)。对照 .serena/memories/testing.md 及其下游的各模块文档,配合本仓库内真实的 runner 配置、包脚本与 fixture 构建器,你既能写出面向行为、抗重构的测试,也能在 JVM 与 JS 两套运行环境之间自如穿梭,把每一次代码变更都变成"可证明的"工作。
【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考