☰
在 Dillinger 中落地可靠测试套件:测试金字塔、AAA 模式与 Mock 策略实战指南
2026/9/27 23:39:53 网站建设 项目流程
  • 前端
  • 开发工具

【免费下载链接】dillinger

The last Markdown editor, ever.

项目地址:https://gitcode.com/gh_mirrors/di/dillinger
点击查看免费下载

本文以 Dillinger(Next.js + React + TypeScript 实现的 Markdown 编辑器)仓库中的真实测试工程为依托,系统讲解测试金字塔、AAA 模式、测试类型选择、单元/集成测试原则、Mock 策略、测试组织、测试数据与反模式等软件测试核心方法论,并逐一对照仓库中的vitest单元测试、路由集成测试与playwrightE2E 测试给出可复制的落地示例。读完本文,你将掌握一套"既有方法论骨架、又有源码级实证"的测试编写规范,并可直接套用到任意前端项目中。


1. 测试金字塔:让测试套件拥有正确的层级配比

测试金字塔是构建测试策略的起点。它的核心思想是:底层单元测试数量最多、速度最快、执行成本最低;越往上层,测试越少、越慢、越接近真实用户路径。

/\ E2E (Few) / \ Critical flows /----\ / \ Integration (Some) /--------\ API, DB queries / \ /------------\ Unit (Many) Functions, classes
  • 单元测试(Many):覆盖函数、类等最小逻辑单元,数量最多,执行毫秒级完成。
  • 集成测试(Some):覆盖 API、数据库查询等跨模块协作路径,数量适中。
  • E2E 测试(Few):只覆盖关键用户流程,数量最少,因为其运行最慢、最脆弱。

Dillinger 仓库正是按这一金字塔组织的:tests/lib/、tests/store/、tests/hooks/、tests/components/下是大量单元测试;tests/routes/下是对 Next.js API Route 的集成测试;tests/e2e/下是少量 Playwright 端到端测试(如 editor.spec.ts)。从目录布局即可看出,"多而快"的测试被放在金字塔底部,这正是金字塔原则在真实项目中的直接体现。

2. AAA 模式:每个测试的固定三段式结构

AAA(Arrange–Act–Assert)是编写清晰测试的统一范式:

StepPurpose
Arrange准备测试数据(Set up test data)
Act执行被测代码(Execute code under test)
Assert验证结果(Verify outcome)

在 store.test.ts 中可以逐行看到 AAA 的完整落地。以hydrate的用例为例:

it("restores documents from localStorage", () => { // Arrange:写入 localStorage 作为测试数据 const doc = createTestDocument({ id: "stored-1", title: "Stored.md" }); localStorage.setItem("files", JSON.stringify([doc])); localStorage.setItem("currentDocument", JSON.stringify(doc)); // Act:执行被测动作 useStore.getState().hydrate(); // Assert:校验副作用结果 const state = useStore.getState(); expect(state.documents).toHaveLength(1); expect(state.documents[0]?.id).toBe("stored-1"); expect(state.currentDocument?.id).toBe("stored-1"); });

再如 export-markdown.route.test.ts 中对POST /api/export/markdown的断言:

it("returns 400 when markdown body is missing", async () => { // Arrange const response = await exportMarkdown(buildRequest({ title: "no-body.md" })); // Act(发生在 await 中)之后直接 Assert expect(response.status).toBe(400); expect(json.error).toBe("Markdown content is required"); });

坚持 AAA 三段式,能让测试的意图一目了然,任何读者都能快速定位"数据从哪来、测了什么、期望什么"。

3. 测试类型选择:何时用哪种测试

TypeBest ForSpeed
UnitPure functions, logicFast(参考阈值 <50ms)
IntegrationAPI, DB, servicesMedium
E2ECritical user flowsSlow

Dillinger 的工程配置与之一一对应:

  • 单元测试由 Vitest 驱动,配置见 vitest.config.ts:environment: "jsdom",include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"]。典型如 markdown.test.ts 中renderMarkdown的纯函数渲染断言、store.test.ts 中 Zustand store 的状态迁移断言。
  • 集成测试同样基于 Vitest,但通过文件头注释// @vitest-environment node切换到 Node 环境,直接调用路由处理函数并断言 HTTP 状态码与响应头,见 export-markdown.route.test.ts。
  • E2E 测试由 Playwright 驱动,配置见 playwright.config.ts:testDir: "./tests/e2e",通过webServer自动启动npx next dev -H 127.0.0.1 -p 3005供测试访问(配置注释说明:因生产构建存在既有预渲染错误,E2E 使用 dev 模式)。

选择原则:优先用单元测试覆盖逻辑;跨模块交互交给集成测试;只有核心用户流程(新建、切换、删除文档、Zen 模式、设置持久化等)才动用 E2E,见 editor.spec.ts。

4. 单元测试原则

好的单元测试应具备的五个属性

PrincipleMeaning
Fast参考阈值 <100ms each
Isolated无外部依赖(No external deps)
Repeatable结果始终一致(Same result always)
Self-checking无需人工验证(No manual verification)
Timely与代码同步编写(Written with code)

Dillinger 的单元测试通过两层机制保障这五个属性:

  1. 隔离环境:vitest.setup.ts 为每个测试文件注入localStorage、matchMedia与ResizeObserver的内存实现,使组件与 store 测试完全不依赖真实浏览器 API;vitest.config.ts开启restoreMocks: true与clearMocks: true,保证 mock 状态在用例之间自动复位。
  2. 确定性输入:markdown.test.ts 使用固定 Markdown 字符串断言固定的 HTML 输出,例如"**bold text**"断言输出<strong>bold text</strong>,并专门用一组"一致性"用例验证renderMarkdown对同一输入多次调用返回相同结果——这正是 Repeatable 原则的测试化表达。

什么该测、什么不该测

TestDon't Test
Business logicFramework code
Edge casesThird-party libs
Error handlingSimple getters

仓库中的边界用例很有参考价值:store.test.ts专门验证"localStorage 中 JSON 损坏时hydrate不崩溃、状态保持不变"(捕获SyntaxError并打印Failed to hydrate state:),useGitHub.test.ts(tests/hooks/useGitHub.test.ts)则系统覆盖"网络错误、非 2xx 响应时各 API 方法优雅降级"——这些都是典型的"边界与错误处理"应当测试的场景。

5. 集成测试原则

测什么

AreaFocus
API endpointsRequest/response
DatabaseQueries, transactions
External servicesContracts

Dillinger 的集成测试聚焦 API endpoints:tests/routes/ 下的用例直接构造Request对象调用路由的POST处理器,断言状态码、Content-Disposition文件名(如Draft.md、My_Notes.md)、Content-Type: text/markdown; charset=utf-8、空正文返回 400、非法 JSON 返回 500 等契约行为。路由测试以请求/响应契约为核心,不依赖真实浏览器。

设置与清理

PhaseAction
Before All连接资源(Connect resources)
Before Each重置状态(Reset state)
After Each清理(Clean up)
After All断开连接(Disconnect)

对应实现:

  • 单元/组件级:vitest.setup.ts 通过afterEach统一执行cleanup()(卸载 Testing Library 渲染的 DOM)并localStorage.clear();store.test.ts与settings-modal.test.tsx(tests/components/settings-modal.test.tsx)在beforeEach中调用resetStore()把 Zustand store 恢复到初始快照。
  • E2E 级:playwright.config.ts 配置trace: "on-first-retry"、screenshot: "only-on-failure"、video: "retain-on-failure",失败即自动留痕,便于排查;测试内通过page.addInitScript在页面加载前注入localStorage种子数据,实现"可重复、无脏状态"的用例隔离。

6. Mock 原则

何时 Mock

MockDon't Mock
外部 API(External APIs)被测代码本身(The code under test)
数据库(单元测试中)简单依赖(Simple dependencies)
时间/随机数(Time/random)纯函数(Pure functions)
网络(Network)内存存储(In-memory stores)

Mock 类型

TypeUse
Stub返回固定值(Return fixed values)
Spy记录调用(Track calls)
Mock设置预期(Set expectations)
Fake简化实现(Simplified implementation)

Dillinger 的测试提供了教科书级的 Mock 范例:

  • Stub + Spy 结合:useGitHub.test.ts 在beforeEach中用vi.stubGlobal("fetch", fetchMock)替换全局 fetch,并让fetchMock = vi.fn(() => mockFetchResponse({ connected: false, user: null }))——既能按 URL 返回固定响应(Stub),又能事后断言fetchMock被以哪些参数调用(Spy),例如expect(fetchMock).toHaveBeenCalledWith("/api/github/status")。
  • Fake:vitest.setup.ts 用Map实现了一个完整的localStorageFake,并 stub 掉matchMedia与ResizeObserver,使 jsdom 环境下组件测试可运行。
  • 按 URL 分流的响应编排:useGitHub.test.ts中大量使用fetchMock.mockImplementation((url) => ...),按/api/github/status、/api/github/repos?owner=...、/api/github/branches?...、/api/github/files?...分别返回不同数据,模拟一个完整的仓库浏览状态机。
  • 编辑器实例的 Mock:store.test.ts 用vi.fn()伪造Monaco.editor.IStandaloneCodeEditor(getSelection、executeEdits、focus),从而在不启动 Monaco 的情况下验证insertMarkdownAtCursor会以"dillinger-inline-insert"为资源标识调用executeEdits。

关键边界:只 Mock 外部依赖(网络、全局 API、重型第三方),绝不 Mock 被测代码自身。上述测试全部围绕真实组件、真实 hook、真实 store 逻辑展开,Mock 的只是它们的环境依赖。

7. 测试组织:命名与分组

命名模式

PatternExample
Should behavior"should return error when..."
When condition"when user not found..."
Given-when-then"given X, when Y, then Z"

分组结构

LevelUse
describe分组相关测试(Group related tests)
it/test单个用例(Individual case)
beforeEach公共初始化(Common setup)

Dillinger 的测试命名可以归纳为三种风格,均满足"描述性命名即文档"的要求:

  1. 行为驱动式:如"hydrates a default document when storage is empty"、"creates a new imported document without overwriting the current one"(store.test.ts)。
  2. 能力边界式:如"fetchBranches handles network error gracefully"、"fetchFileContent returns null for non-ok API response"(useGitHub.test.ts)。
  3. 功能断言式:如"renders checked checkboxes"、"applies bootstrap table classes to tables"(markdown.test.ts)。

分组上,store.test.ts 用嵌套describe("createDocument" / "hydrate" / "persist" / "deleteDocument" ...)把同一 action 的多个用例聚在一起;E2E 侧 editor.spec.ts 则用test.describe("Document creation" / "Zen mode" / "Settings modal" ...)按用户流程组织,beforeEach中统一执行种子数据注入。

8. 测试数据策略

三种数据生成方式

ApproachUse
Factories生成测试数据(Generate test data)
Fixtures预定义数据集(Predefined datasets)
Builders流式对象创建(Fluent object creation)

Dillinger 仓库同时使用了 Factory 与 Fixture 两种策略,可作为参考样板:

  • Factory(工厂函数):store.test.ts 定义createTestDocument(overrides),以默认值 + 覆盖项的方式快速生成文档对象:
function createTestDocument(overrides: Partial<{ id: string; title: string; body: string; createdAt: string; }> = {}) { return { id: overrides.id ?? "doc-1", title: overrides.title ?? "Test.md", body: overrides.body ?? "# Test", createdAt: overrides.createdAt ?? "2026-03-10T00:00:00.000Z", }; }
  • Fixture(固定数据集):editor.spec.ts 定义seededDocument、secondDocument、defaultProfile作为 E2E 种子,并通过seedSingleDocument(page)/seedMultipleDocuments(page)封装page.addInitScript,在页面加载前写入files、currentDocument、profileV3三个 localStorage 键。

数据原则

  • 使用贴近真实的数据(如文档标题带.md后缀、body 为真实 Markdown)
  • 对非关键字段随机化(仓库中测试因追求确定性而使用固定值,随机化工具如 faker 可按需引入)
  • 共享公共 fixtures(defaultProfile在多文件间复用)
  • 保持数据最小化(只放被测路径需要的字段)

9. 最佳实践清单

PracticeWhy
每个测试一个核心断言(One assert per test)失败原因清晰(Clear failure reason)
测试相互独立(Independent tests)无顺序依赖(No order dependency)
测试保持快速(Fast tests)可以频繁运行(Run frequently)
描述性命名(Descriptive names)自文档化(Self-documenting)
及时清理(Clean up)避免副作用(Avoid side effects)

这些实践在 Dillinger 工程中已被制度化:

  • 独立性:store.test.ts的resetStore()、beforeEach中的localStorage.clear(),加上vitest.config.ts的restoreMocks/clearMocks,共同保证用例之间零状态泄漏。
  • 快速:单元与组件测试在 jsdom 中毫秒级完成;package.json把测试拆分为test:unit(vitest run)与test:e2e,日常开发可只跑前者,避免被 E2E 拖慢节奏。
  • 清理:vitest.setup.ts的afterEach统一执行 Testing Librarycleanup()与存储清空,组件测试无需各自手动卸载。
  • 可观测:Playwright 失败自动产出 trace、截图与视频(见 playwright.config.ts),把排查成本压到最低。

10. 反模式:这些做法必须避免

❌ Don't✅ Do
测试实现细节(Test implementation)测试行为(Test behavior)
重复测试代码(Duplicate test code)使用工厂(Use factories)
复杂测试搭建(Complex test setup)简化或拆分(Simplify or split)
忽略偶发失败(Ignore flaky tests)修复根因(Fix root cause)
跳过清理(Skip cleanup)重置状态(Reset state)

仓库中的正面示例恰是这些反模式的镜像:

  • 测行为而非实现:markdown.test.ts 只断言渲染产出的 HTML 结构与内容,从不断言 markdown-it 的内部调用;settings-modal.test.tsx 通过getByRole("switch", ...)与aria-checked断言可访问的行为,而非组件内部 state 变量。
  • 用工厂消灭重复:createTestDocument、seedSingleDocument正是为消除"每个用例手写 localStorage 种子"的重复而存在。
  • 直面失败而非忽略:useGitHub.test.ts对网络错误、非 2xx 响应逐一断言"优雅降级"结果,而不是try/catch吞掉异常;Playwright 的trace: "on-first-retry"则是为偶发失败保留诊断证据、定位根因。
  • 主动重置状态:从beforeEach的resetStore()到afterEach的cleanup(),再到 vitest 配置级的 mock 复位,形成三层清理防线。

11. 在 Dillinger 仓库中运行与验证

仓库在 package.json 中提供了完整的测试命令矩阵:

npm run test:unit # vitest run —— 跑单元 + 路由集成测试 npm run test:watch # vitest —— 监听模式,开发时持续反馈 npm run test:e2e # npm run build && playwright test —— 构建后跑 E2E npm run test:e2e:headed # 以有头浏览器模式运行 E2E,便于观察 npm run test # 依次执行 test:unit 与 test:e2e npm run verify # lint + typecheck + 单元 + E2E 全量门禁

其中verify把 lint(next lint)、typecheck(tsc --noEmit)、单元测试与 E2E 串成一条发布前门禁;start:test(next start -H 127.0.0.1 -p 3005)则为手动联调提供与 E2E 一致的端口。E2E 依赖的浏览器与服务器由 playwright.config.ts 管理:webServer自动拉起npx next dev -H 127.0.0.1 -p 3005,reuseExistingServer: !process.env.CI允许本地复用已启动的实例。

12. 测试即文档

Remember:Tests are documentation. If someone can't understand what the code does from the tests, rewrite them.

(测试就是文档。如果别人无法通过测试理解代码在做什么,就重写它们。)

这条原则在 Dillinger 仓库中得到了彻底执行:markdown.test.ts 完整记录了renderMarkdown支持的全部 Markdown 能力(标题、粗斜体、高亮、脚注、上下标、删除线插入、定义列表、缩写、目录、KaTeX 数学公式、行号属性、表格类名、标题锚点);useGitHub.test.ts 完整刻画了 GitHub 集成的状态机(status → orgs → repos → branches → files → file content → save → unlink);store.test.ts 则穷举了 store 的 hydrate、persist、CRUD 与异常分支。任何新成员只需读一遍测试,就能在不看实现的情况下掌握系统行为契约——这正是测试作为文档的最高价值形态。


延伸阅读:测试配置详见 vitest.config.ts 与 playwright.config.ts;全局测试环境与 mock 见 vitest.setup.ts;单元测试样本见 tests/lib/、tests/store/、tests/hooks/、tests/components/;路由集成测试见 tests/routes/;端到端测试见 tests/e2e/。

  • 前端
  • 开发工具

【免费下载链接】dillinger

The last Markdown editor, ever.

项目地址:https://gitcode.com/gh_mirrors/di/dillinger
点击查看免费下载
上一篇:Roc 名义类型模块中关联嵌套类型的前向引用:从快照测试看 `ModType.InternalType` 的解析与校验
下一篇:jql:革命性JSON查询工具,用Lispy语法轻松处理复杂数据

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

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

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

立即咨询