关于 wangEditor-v5 单元测试的总结

前言

单元测试作为优秀软件不可或缺的一部分,高质量的单元测试和一定的测试覆盖率是衡量一个开源项目是否合格的重要标准,没有任何测试的软件相信没有任何人敢用。

v5 是 wangEditor 变革性的一个版本,无论是从底层选型,还是架构设计,亦或是工程化的角度来看,它是更接近现代化 web 开发模式的一个产品。想更加详细了解我们 v5 的架构,可以看我们往期文章:基于 slate.js(不依赖 React)设计富文本编辑器

笔者主要负责项目中的单元测试、E2E 测试、CI & CD 等工作,从去年十月份开始设计整个项目的自动化测试,并且全程参与项目的单元测试的编写。因为工作原因,投入的时间也是断断断续,到这两周算是才有一定的进展。有以下原因:

  • 富文本场景因为其特殊性给单元测试带来了一定的困难,选区、光标位置等数据在测试过程具有一定的 mock 难度;
  • 对于表格这样复杂的菜单功能,其本身的代码量和设计也一定程度上决定了单测的难度;
  • 当然对于一个完整的富文本编辑器,功能非常多也是一个原因,除了常见的菜单功能,还有上传功能、插件化等都一定程度上给单测带来了成本。

目前的进展是:

  • 单元测试已经基本覆盖了所有菜单功能,几乎所有菜单的代码覆盖率在 90% 以上;
  • 核心的上传模块功能已经基本覆盖,其它 core package 下的功能整体覆盖率还比较低,这也是整体覆盖率不是很高的原因。

虽然衡量一个软件是否优秀不能完全只看代码覆盖率,毕竟在 v5 是在 v4 版本上的一个版本,v4 我们还是有一定的用户数量,所以我们积累了大量的 user stories 和踩过 4000+ 的坑,在 v5 中我们的功能基本覆盖了所有踩过的坑。话虽如此,但我们还是希望通过一定的代码测试覆盖保证编辑器的质量,也能保证开发项目过程中的代码质量。

下面总结下最近几个月我在写 v5 单元测试中的一些思考。

基于交互测试还是基于状态测试

对单元测试有一定了解的同学肯定知道在单元测试中,在需要验证代码期望行为的时候,有两种常用的方法:基于状态和基于交互。

基于状态的测试一般是利用对象内部状态来验证执行结果的正确性,我们需要获取待测对象的状态,然后与期望的状态做对比,进行验证。

基于交互的测试则是验证待测对象与其协作对象以我们期望的方式进行交互,而非验证这些对象的最终状态是否匹配。

到底选择交互测试还是状态测试?在写 v5 的单元测试的时候,我也遇到了同样的困扰。从宏观的架构看 v5,我们的编辑器是基于 slate.js MVC 架构开发的,按道理我们应该更加关注代码执行后编辑器的内容,也就是上面提到的状态。看下面的一个例子:

exec(editor: IDomEditor, value: string | boolean) {
    const { allowedFileTypes = [], customBrowseAndUpload } = this.getMenuConfig(editor)

    // 自定义选择视频,并上传
    if (customBrowseAndUpload) {
      customBrowseAndUpload(src => insertVideo(editor, src))
      return
    }

    // 设置选择文件的类型
    let acceptAttr = ''
    if (allowedFileTypes.length > 0) {
      acceptAttr = `accept="${allowedFileTypes.join(', ')}"`
    }

    // 添加 file input(每次重新创建 input)
    const $body = $('body')
    const $inputFile = $(`<input type="file" ${acceptAttr} multiple/>`)
    $inputFile.hide()
    $body.append($inputFile)
    $inputFile.click()
    // 选中文件
    $inputFile.on('change', () => {
      const files = ($inputFile[0] as HTMLInputElement).files
      uploadVideo(editor, files) // 上传文件
    })
  }
复制代码

这是我们插入视频的一个菜单功能,在这个例子中,在写单元测试的时候其实我更加关注的是触发 input change 事件后是否调用了 uploadVideo这个核心的方法,而这个方法调用了 core 模块中上传模块的基础方法,这就是典型的适合使用交互测试的场景,而不只是上传后视频后编辑内容中是否多了一个 video 的节点这样的状态。

一般基于交互的测试比基于状态的测试会更加繁琐,因为基于交互的测试需要引入一定的模拟对象;但是基于状态的测试有时却不能完全达到我们想要测试的效果,因为当代码功能复杂的时候,我们往往需要关注许多不同对象之间的交互,而非简单的状态变化

所以不用太纠结使用交互测试还是状态测试,它们都非常有用,两者结合才能覆盖所有代码中需要待测的点。

怎么使用好伪实现

并不是所有的代码都只是简单的输入 -> 输出,如果都是这样,那代码测试的世界会变得非常简单。特别是对于富文本这样的场景,虽然我们大多数功能都是一直在修改 editor对象里面的 content,但是光从代码上看,我们有时候甚至很容易忽略这个事实。我在写 v5 单元测试的时候,也会犯这个毛病。

那么如何正确地对待这样的代码测试场景,特别是对于非常复杂的场景,我们甚至非常难模拟某个功能正确运行的一些状态。看下面这个例子:

exec(editor: IDomEditor, value: string | boolean) {
    if (this.isDisabled(editor)) return

    const [cellEntry] = Editor.nodes(editor, {
      match: n => DomEditor.checkNodeType(n, 'table-cell'),
      universal: true,
    })
    const [selectedCellNode, selectedCellPath] = cellEntry

    // 如果只有一列,则删除整个表格
    const rowNode = DomEditor.getParentNode(editor, selectedCellNode)
    const colLength = rowNode?.children.length || 0
    if (!rowNode || colLength <= 1) {
      Transforms.removeNodes(editor, { mode: 'highest' }) // 删除整个表格
      return
    }

    // ------------------------- 不只有 1 列,则继续 -------------------------

    const tableNode = DomEditor.getParentNode(editor, rowNode)
    if (tableNode == null) return

    // 遍历所有 rows ,挨个删除 cell
    const rows = tableNode.children || []
    rows.forEach(row => {
      if (!Element.isElement(row)) return

      const cells = row.children || []
      // 遍历一个 row 的所有 cells
      cells.forEach((cell: Node) => {
        const path = DomEditor.findPath(editor, cell)
        if (
          path.length === selectedCellPath.length &&
          isEqual(path.slice(-1), selectedCellPath.slice(-1)) // 俩数组,最后一位相同
        ) {
          // 如果当前 td 的 path 和选中 td 的 path ,最后一位相同,说明是同一列
          // 删除当前的 cell
          Transforms.removeNodes(editor, { at: path })
        }
      })
    })
复制代码

没有详细看过我们项目源码的同学虽然不一定能完全看懂这段代码,下面我简单解释这段代码的功能。

首先要明确的是,其实这是一个删除表格列的菜单功能。虽然功能只是一句话,但是背后有非常多的状态和逻辑需要判断检查:

  • 首先如果菜单是 disabled 状态,则直接返回;
  • 然后检查当前选区的元素是否包含最细粒度的表格单元格节点,如果没有,说明不是表格内容,直接返回;若果有,则找到单元格的父元素,也就是表格行节点;
  • 然后如果没有父元素(说明表格 dom 结构异常)或者只有当前表格只有一行内容,则直接删除整个表格(特殊的业务逻辑);
  • 最后一步才会遍历所有的单元格,将对应的单元格进行删除。

虽然函数的功能结果是删除了表格的一列或者一整个表格,但是整个代码执行过程,我们同样也需要关注。在测试本功能的时候,你要保证在对应的对象状态下代码执行到对应的逻辑。在该场景的测试中,最难做的是构造不同的对象状态,需要模拟出所有场景,你会发现这非常的困难。

不要担心,发挥单测中的威力强大的伪实现的时候到了。这时候我们可以通过构造一些 stub对象,来替换代码中的一些实现,让它们返回我们想要的结果。话不多说,看一个例子:

test('exec should invoke removeNodes method to remove whole table if menu is not disabled and table col length less than 1', () => {
    const deleteColMenu = new DeleteCol()
    const editor = createEditor()

    // 这里利用伪实现替换 isDisabled 的实现
    jest.spyOn(deleteColMenu, 'isDisabled').mockImplementation(() => false)
    // 这里利用伪实现替换 getParentNode 的实现
    jest.spyOn(core.DomEditor, 'getParentNode').mockImplementation(() => ({
      type: 'table-col',
      children: [],
    }))

    const fn = function* a() {
      yield [
        {
          type: 'table-cell',
          children: [],
        } as slate.Element,
        [0, 1],
      ] as slate.NodeEntry<slate.Element>
    }
    jest.spyOn(slate.Editor, 'nodes').mockReturnValue(fn())
    // 这里 Mock removeNodes,方便最后测试断言
    const removeNodesFn = jest.fn()
    jest.spyOn(slate.Transforms, 'removeNodes').mockImplementation(removeNodesFn)

    deleteColMenu.exec(editor, '')
    // 这里断言最后调用了我们期望调用的方法
    expect(removeNodesFn).toBeCalled()
  })
复制代码

虽然伪实现非常强大,但是我们总是建议只有在对象状态非常难以模拟的时候,我们再考虑使用此项技术进行测试。特别是测试一些依赖第三方库或者服务的代码的时候,我们真的非常难控制它们的状态,这时候就可以实现伪实现来进行测试。

上面提到了伪实现、stub 等概念,如果对于这两个概念不了解的同学,可以看我往期的文章:Jest 模拟技术在wangEditor中的实践,里面也介绍了如何利用 Jest 实现强大的模拟技术。

当然上面的代码除了因为场景问题,导致本来就非常难模拟状态,从而不好编写单元测试。其实从代码的设计角度来看,其代码的抽象过于分散,这也是比较难测试的原因之一。所以下面总结几个如何设计可测试代码的原则。

可测试代码的准则

1.尽量使用组合而非继承

如何从各个独立的功能构建出复杂的对象,对代码的可测试性影响很大。在面向对象语言中,一般通过继承以及组合来实现这类功能。

继承是比较传统的重用功能,虽然这种方法在一定程度上也不错,但是继承在设计的可测试性、可维护性和复杂性方面都有负面影响。因为一旦子类继承了父类,那么就必须接受父类所拥有的所有功能,而在扩展父类功能的时候,就算子类不需要此功能,也必须无条件接受。

在我们 v5 的设计中,一些菜单因为继承了 BaseMenu,所以对于一些菜单,无可避免引入非必要的功能:

abstract class BaseMenu implements IDropPanelMenu {
  abstract readonly title: string
  abstract readonly iconSvg: string
  readonly tag = 'button'
  readonly showDropPanel = true // 点击 button 时显示 dropPanel
  protected abstract readonly mark: string
  private $content: Dom7Array | null = null

  exec(editor: IDomEditor, value: string | boolean) {
    // 点击菜单时,弹出 droPanel 之前,不需要执行其他代码
    // 此处空着即可
  }

  getValue(editor: IDomEditor): string | boolean {
    // 省略代码
  }

  isActive(editor: IDomEditor): boolean {
    // 省略代码
  }

  isDisabled(editor: IDomEditor): boolean {
    // 省略代码
  }

  getPanelContentElem(editor: IDomEditor): DOMElement {
    // 省略代码
  }
}
 
// 无用的 exec 行为
class BgColorMenu extends BaseMenu {
  readonly title = t('color.bgColor')
  readonly iconSvg = BG_COLOR_SVG
  readonly mark = 'bgColor'
}
复制代码

将各种行为抽象为更加具体的类或者接口,通过组合的方式能更灵活重用类的功能。

2.隔离依赖

为了更加方便使用测试替身替换依赖,隔离依赖使其更加容易替换非常关键。特别是当我们的项目依赖了对于第三方的一些实现,应该尽量将第三方依赖隔离。

重构-改善既有代码的设计 一书中,作者提到了接缝( seams) 的概念:不用修改直接影响行为的代码就能改变系统行为的那个点。通俗地讲,就是可以在测试期间,可以在某个点用一段代码替换另一段代码,而无需修改待测试代码,这个点就是接缝。下面看一个例子:

it('it should keep inline children', () => {
    const $elem = $('<blockquote></blockquote>')
    const children: any[] = [
      { text: 'hello ' },
      { type: 'link', url: 'http://wangeditor.com' },
      { type: 'paragraph', children: [] },
    ]

    const isInline = editor.isInline
    // 这里方便我们测试 inline child 场景,修改 isInline 的实现
    editor.isInline = (element: any) => {
      if (element.type === 'link') return true
      return isInline(element)
    }

    // parse
    const res = parseHtmlConf.parseElemHtml($elem[0], children, editor)
    expect(res).toEqual({
      type: 'blockquote',
      children: [{ text: 'hello ' }, { type: 'link', url: 'http://wangeditor.com' }],
    })
  })
复制代码

JavaScript 作为一门面向“对象”的语言,一切皆为对象,我们可以直接修改对象的行为。

3.注入依赖

依赖注入(Dependency Injection, DI) 是降低对象之间直接依赖关系的一种有效方式,这种方式可以减少直接依赖,将依赖变为间接依赖。

未命名文件 (1).png

一般有两种依赖注入的方式。一种是 setter-base 的,通过调用 setter 方法注入依赖。另外一种是 field-setter,即在实例初始化的时候,通过给构造函数传入参数注入依赖。

在我们 v5 的上传视频功能中,为了更好测试应该将 uplader 通过依赖注入的方式注入:

class UploadVideoMenu implements IButtonMenu {
  readonly title = t('videoModule.uploadVideo')
  readonly iconSvg = UPLOAD_VIDEO_SVG
  readonly tag = 'button'
  private uploader: Uploader
  
  setUploader (uploader: Uploader) {
    this.uplader = uploader
  }
  
  exec () {
    // 在这里使用 uploader 进行上传功能
  }
}  
复制代码

上面是伪代码,不是现有实现,从可测试性的角度来看,这种实现会更加好。

总结

本文先是介绍了基于交互的测试和基于状态测试的区别,以及怎么选择交互测试和状态测试,怎么利用伪实现测试状态比较难控制的功能场景,最后介绍了编写可测试代码的几个准则。从中我们可以得出以下结论:

  • 无论是基于状态测试还是基于交互测试,它们都各有优势,有各自的适用场景,所以在写测试的时候我们应该结合来使用,完美发挥它们各自的作用
  • 对于复杂的功能,如果需要有大量的状态需要模拟,而且比较难模拟,我们可以通过伪实现来帮助我们进行测试,特别是对于一些依赖第三方服务的方法
  • 怎么编写可测试性的代码,这非常考验开发者的个人能力以及对测试的理解。虽然这里给出了三个设计代码的准则,但是在真正设计代码和实现的过程中,还是有可能写出难以测试的代码。从这个角度来看,TDD 的开发模式有利于我们写出可测试的代码,测试->编码->重构的模式有助于我们写出高代码的质量。

Reference

猜你喜欢

转载自juejin.im/post/7079951448812814349