Summary about wangEditor-v5 unit testing

foreword

Unit testing is an indispensable part of excellent software. High-quality unit testing and certain test coverage are important criteria to measure whether an open source project is qualified. I believe no one dares to use any software without any testing.

v5 is a revolutionary version of wangEditor , whether it is from the bottom-level selection, architecture design, or engineering point of view, it is a product that is closer to the modern web development model . To learn more about our v5 architecture, you can read our previous article: Designing a Rich Text Editor Based on slate.js (without React) .

The author is mainly responsible for unit testing, E2E testing, CI & CD and other work in the project. Since October last year, I have designed the automated testing of the entire project, and participated in the writing of the unit testing of the project throughout the process. Due to work reasons, the time invested is also intermittent, and only some progress has been made in the past two weeks. There are the following reasons:

  • The rich text scene brings certain difficulties to unit testing due to its particularity. Data such as selection area and cursor position have certain mocking difficulties during the testing process;
  • For a complex menu function like a table, the amount of code and design itself determine the difficulty of a single test to a certain extent;
  • Of course, for a complete rich text editor, it is also a reason that there are many functions. In addition to the common menu functions, there are upload functions, plug-ins, etc., which bring costs to the unit test to a certain extent.

The current progress is:

  • Unit tests have basically covered all menu functions, and the code coverage of almost all menus is above 90% ;
  • The core upload module function has been basically covered, and the overall coverage of the functions under other core packages is still relatively low, which is why the overall coverage is not very high.

Although we can't measure whether a software is excellent or not, we can't just look at the code coverage. After all, v5 is a version on the v4 version. We still have a certain number of users in v4, so we have accumulated a large number of user stories and stepped on 4000+ pits , in v5 our function basically covers all the pits that have been stepped on. Having said that, we still hope to ensure the quality of the editor through a certain code test coverage, and also to ensure the quality of the code in the process of developing the project.

Below is a summary of some of my thoughts on writing v5 unit tests in recent months.

Interaction-based or state-based testing

Students who have a certain understanding of unit testing must know that in unit testing, when it is necessary to verify the expected behavior of the code, there are two commonly used methods: state-based and interaction-based.

State-based testing generally uses the internal state of the object to verify the correctness of the execution result. We need to obtain the state of the object to be tested, and then compare it with the expected state for verification.

Interaction-based testing verifies that the object under test and its collaborating objects interact in the way we expect, rather than verifying that the final states of these objects match.

In the end to choose interactive testing or state testing? I had the same problem when writing unit tests for v5. Looking at v5 from a macro-architecture, our editor is developed based on the slate.js MVC architecture. Logically, we should pay more attention to the content of the editor after code execution, which is the state mentioned above . See an example below:

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) // 上传文件
    })
  }
复制代码

This is a menu function we insert into the video. In this example, when writing unit tests, I actually pay more attention input changeto whether uploadVideothe core method is called after the event is triggered, and this method calls the basis of the upload module in the core module. method, this is a typical scenario suitable for interactive testing , not just the state of whether there is an additional video node in the edited content after uploading the 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) 是降低对象之间直接依赖关系的一种有效方式,这种方式可以减少直接依赖,将依赖变为间接依赖。

unnamed file(1).png

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

In our v5 upload video function, the uploader should be injected through dependency injection for better testing :

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 进行上传功能
  }
}  
复制代码

The above is pseudocode, not an existing implementation, which would be better from a testability point of view.

Summarize

This article first introduces the difference between interaction-based testing and state-based testing, how to choose interaction testing and state-based testing, and how to use pseudo-implementation for functional scenarios where test states are difficult to control. Finally, it introduces several guidelines for writing testable code. From this we can draw the following conclusions:

  • Whether it is based on state testing or based on interaction testing, they all have their own advantages and their own applicable scenarios, so when writing tests, we should use them in combination to perfectly play their respective roles ;
  • For complex functions, if a large number of states need to be simulated, and it is difficult to simulate, we can use pseudo-implementation to help us test, especially for some methods that rely on third-party services ;
  • How to write testable code is a test of the developer's personal ability and understanding of testing. Although three guidelines for designing code are given here, it is still possible to write code that is difficult to test in the process of actually designing the code and implementing it. From this point of view, the development mode of TDD helps us write testable code, and the mode of testing->coding->refactoring helps us write high-quality code.

Reference

Guess you like

Origin juejin.im/post/7079951448812814349