给Markdown编辑器写测试:比你想的简单,也比你想的难

上一篇聊完功能设计,这个系列本来打算收尾了。但写代码的过程中有一个话题一直没聊——测试。
WriteFlow用vitest做单元测试。截止目前一共4个测试文件,覆盖了编辑器最核心的逻辑:Markdown解析/序列化、大纲提取、字数统计、国际化完整性。
看起来不多,但编辑器的测试有一些特殊的地方。
WriteFlow里最好测的部分是工具函数,因为它们是纯函数:输入确定,输出确定。
字数统计就是典型的纯函数测试:
import { countWords } from '../../src/renderer/src/utils/wordCount'import { writeflowSchema } from '../../src/renderer/src/editor/schema'function makeDoc(texts: string[]) { const { doc, paragraph } = writeflowSchema.nodes const blocks = texts.map((t) => paragraph.create(null, writeflowSchema.text(t))) return doc.create(null, blocks)}describe('countWords', () => { it('counts English words', () => { const doc = makeDoc(['hello world']) expect(countWords(doc).words).toBe(2) }) it('counts Chinese characters individually', () => { const doc = makeDoc(['你好世界']) expect(countWords(doc).words).toBe(4) }) it('counts mixed Chinese and English', () => { const doc = makeDoc(['Hello 你好 World 世界']) expect(countWords(doc).words).toBe(6) })})
输入一个ProseMirror文档节点,输出词数和字符数。不依赖外部状态,不依赖DOM,跑起来飞快。
大纲提取也一样,纯函数,输入文档,输出标题数组。
Markdown解析:roundtrip测试
Markdown的测试有一个经典模式叫roundtrip:把一段Markdown解析成ProseMirror文档,再序列化回Markdown,结果应该和原始输入一致。
describe('serializeDoc', () => { it('roundtrips plain text', () => { const md = 'hello world' const doc = parseMarkdown(md) expect(serializeDoc(doc).trim()).toBe(md) }) it('roundtrips heading', () => { const md = '## Title' expect(serializeDoc(parseMarkdown(md)).trim()).toBe(md) }) it('roundtrips code block', () => { const md = '```js\nconsole.log("hi")\n```' expect(serializeDoc(parseMarkdown(md)).trim()).toBe(md) }) it('roundtrips inline math', () => { const md = '$E=mc^2$' expect(serializeDoc(parseMarkdown(md)).trim()).toBe(md) })})
Roundtrip测试的价值在于:它验证的不是某个具体功能,而是整个解析和序列化管道的对称性。如果你改了解析逻辑导致输出格式变了,roundtrip测试会立刻失败。
当然,有些场景roundtrip做不到完全一致。比如空行数量、列表缩进风格,原始格式和序列化后的格式可能有差异。但核心内容一定是一致的:标题级别、代码块语言标记、数学公式内容、图片路径。
测试ProseMirror文档:构造数据的方式很关键
ProseMirror文档是一棵树。测试的第一步是构造这棵树。
有两种方式:用ProseMirror的API手动创建节点,或者用parseMarkdown把Markdown字符串解析成文档树。WriteFlow两种都用。
大纲提取测试用了手动创建节点,因为需要精确控制heading的层级和数量:
function makeDoc(content: string) { const { doc, paragraph, heading } = writeflowSchema.nodes const blocks: any[] = [] for (const line of content.split('\n')) { const match = line.match(/^(#{1,6})\s+(.*)/) if (match) { blocks.push(heading.create({ level: match[1].length }, writeflowSchema.text(match[2]))) } else if (line.trim()) { blocks.push(paragraph.create(null, writeflowSchema.text(line))) } } return doc.create(null, blocks)}
这个makeDoc函数接受一个类似Markdown的字符串,但直接用Schema API构建文档,不走解析器。好处是你可以测试大纲提取的逻辑,而不依赖解析器的正确性。
而Markdown解析测试直接用parseMarkdown,因为测试的就是解析器本身。两种方式各有所用。
编辑器测试难在哪里
说到现在,测试的都是纯函数和解析逻辑。真正难测的是编辑器行为:用户输入、光标移动、选区变化、快捷键触发、视图更新。
这些东西都依赖DOM和浏览器环境。在Node.js的测试框架里跑不了。
ProseMirror本身的测试策略是把核心逻辑(state、transform、schema)和视图层(view、DOM操作)分开。核心逻辑是纯JavaScript,可以在Node.js里测。视图层需要真实的DOM,通常用jsdom或者真实的浏览器环境。
WriteFlow目前只测了核心逻辑层。视图层的测试如果要加,需要引入jsdom或者用Playwright做E2E测试。这部分还没做,后面如果有需要会补上。
i18n完整性测试:防止翻译漏掉
这是一个很容易被忽略但非常有价值的测试类型:
import en from '../../src/renderer/src/i18n/locales/en.json'import zhCN from '../../src/renderer/src/i18n/locales/zh-CN.json'import zhTW from '../../src/renderer/src/i18n/locales/zh-TW.json'describe('i18n locale completeness', () => { const enKeys = Object.keys(en).sort() it('zh-CN has all keys from en', () => { expect(Object.keys(zhCN).sort()).toEqual(enKeys) }) it('zh-TW has all keys from en', () => { expect(Object.keys(zhTW).sort()).toEqual(enKeys) }) it('no empty translation values', () => { for (const [locale, data] of Object.entries(locales)) { for (const [key, value] of Object.entries(data)) { expect(value, `${locale}.${key} is empty`).toBeTruthy() } } })})
以英文为基准语言,检查简体中文和繁体中文是否覆盖了所有key,且没有空值。每次新增UI文案的时候,如果忘了更新其他语言的翻译文件,这个测试会立刻报错。
WriteFlow支持三种语言:简体中文、English、繁体中文。语言检测逻辑也有意思:先看localStorage有没有存过,再看浏览器的navigator.language,繁体中文(zh-TW、zh-HK、zh-Hant)走繁体,其他中文走简体,其余走英文。
AI生成测试用例
WriteFlow的测试用例大部分是我和AI一起写的。
过程是这样的:我先描述要测什么功能,AI生成测试代码。然后我会审查几个点:
边界情况有没有覆盖。 比如空文档、只有空格的文本、混合中英文。AI有时候会漏掉边界case,需要我手动补。
断言够不够精确。 AI有时候写 expect(result.words).toBeGreaterThan(0) 这种模糊断言。我会改成精确的数值,比如 expect(result.words).toBe(6)。模糊断言通过了不代表逻辑是对的。
测试结构合不合理。 AI有时候会把多个场景塞进一个test case。我会拆成独立的it块,每个只测一个场景。失败了能立刻知道是哪个场景出问题。
测试代码和生产代码一样需要review。AI能帮你生成,但不能帮你判断测试覆盖是不是够。
测试策略的选择
不是所有项目都需要100%测试覆盖率。WriteFlow目前的策略是:
-
• 核心逻辑(解析、序列化、工具函数)写单元测试,因为它们是纯函数,好写好跑 -
• i18n写完整性检查,防止漏翻译 -
• 视图层和交互行为暂不测试,性价比不高 -
• E2E测试等用户量大了再做
对个人开发者来说,测试的投入要有取舍。先把最容易出bug的部分覆盖了,比追求覆盖率有意义得多。
GitHub:
https://github.com/jaqen6688/WriteFlow
百度网盘(国内下载):https://pan.baidu.com/s/1m8Tad11Itl0Yw9D5kqBTpA?pwd=rlb2
我在「全栈生涯」等你,关注就能找到。
夜雨聆风