乐于分享
好东西不私藏

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

给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

我在「全栈生涯」等你,关注就能找到。