Résumé sur les tests unitaires de wangEditor-v5

avant-propos

Les tests unitaires sont une partie indispensable d'un excellent logiciel. Des tests unitaires de haute qualité et une certaine couverture de test sont des critères importants pour mesurer si un projet open source est qualifié. Je crois que personne n'ose utiliser un logiciel sans aucun test.

v5 est une version révolutionnaire de wangEditor , que ce soit du point de vue de la sélection de bas niveau, de la conception de l'architecture ou de l'ingénierie, c'est un produit plus proche du modèle de développement Web moderne . Pour en savoir plus sur notre architecture v5, vous pouvez lire notre article précédent : Concevoir un éditeur de texte enrichi basé sur slate.js (sans React) .

L'auteur est principalement responsable des tests unitaires, des tests E2E, des CI & CD et d'autres travaux du projet. Depuis octobre de l'année dernière, j'ai conçu les tests automatisés de l'ensemble du projet et participé à l'écriture des tests unitaires du projet. tout au long du processus. Pour des raisons de travail, le temps investi est également intermittent, et seuls quelques progrès ont été réalisés au cours des deux dernières semaines. Il y a les raisons suivantes :

  • La scène de texte riche apporte certaines difficultés aux tests unitaires en raison de sa particularité.Des données telles que la zone de sélection et la position du curseur présentent certaines difficultés de moquerie pendant le processus de test ;
  • Pour une fonction de menu complexe comme un tableau, la quantité de code et la conception elle-même déterminent dans une certaine mesure la difficulté d'un seul test ;
  • Bien sûr, pour un éditeur de texte enrichi complet, c'est aussi une raison pour laquelle il existe de nombreuses fonctions. En plus des fonctions de menu communes, il existe des fonctions de téléchargement, des plug-ins, etc., qui font grimper les coûts du test unitaire à un certaine mesure.

La progression actuelle est de :

  • Les tests unitaires ont essentiellement couvert toutes les fonctions de menu, et la couverture de code de presque tous les menus est supérieure à 90 % ;
  • La fonction du module de téléchargement de base a été essentiellement couverte, et la couverture globale des fonctions sous d'autres packages de base est encore relativement faible, c'est pourquoi la couverture globale n'est pas très élevée.

Bien que nous ne puissions pas mesurer si un logiciel est excellent ou non, nous ne pouvons pas simplement regarder la couverture de code. Après tout, la v5 est une version sur la version v4. Nous avons encore un certain nombre d'utilisateurs dans la v4, donc nous ont accumulé un grand nombre d'histoires d'utilisateurs et ont marché sur plus de 4000 fosses, dans la v5, notre fonction couvre essentiellement toutes les fosses qui ont été piétinées. Cela dit, nous espérons toujours assurer la qualité de l'éditeur à travers une certaine couverture de test de code, et également assurer la qualité du code dans le processus de développement du projet.

Vous trouverez ci-dessous un résumé de certaines de mes réflexions sur l'écriture de tests unitaires v5 au cours des derniers mois.

Tests basés sur l'interaction ou sur l'état

Les étudiants qui ont une certaine compréhension des tests unitaires doivent savoir que dans les tests unitaires, lorsqu'il est nécessaire de vérifier le comportement attendu du code, il existe deux méthodes couramment utilisées : basée sur l'état et basée sur l'interaction.

Les tests basés sur l'état utilisent généralement l'état interne de l'objet pour vérifier l'exactitude du résultat d'exécution. Nous devons obtenir l'état de l'objet à tester, puis le comparer avec l'état attendu pour vérification.

Les tests basés sur l'interaction vérifient que l'objet testé et ses objets collaborateurs interagissent de la manière attendue, plutôt que de vérifier que les états finaux de ces objets correspondent.

Au final choisir les tests interactifs ou les tests d'état ? J'ai eu le même problème lors de l'écriture de tests unitaires pour la v5. En regardant la v5 à partir d'une macro-architecture, notre éditeur est développé sur la base de l' architecture MVC slate.js.En toute logique, nous devrions faire plus attention au contenu de l'éditeur après l'exécution du code, qui est l' état mentionné ci-dessus . Voir un exemple ci-dessous :

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

Ceci est une fonction de menu que nous insérons dans la vidéo. Dans cet exemple, lors de l'écriture de tests unitaires, je fais plus attention input changeà savoir si uploadVideola méthode principale est appelée après le déclenchement de l'événement, et cette méthode appelle la base du module de téléchargement dans le module de base., il s'agit d'un scénario typique adapté aux tests interactifs , pas seulement l'état de savoir s'il existe un nœud vidéo supplémentaire dans le contenu édité après le téléchargement de la vidéo.

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

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

怎么使用好伪实现

并不是所有的代码都只是简单的输入 -> 输出,如果都是这样,那代码测试的世界会变得非常简单。特别是对于富文本这样的场景,虽然我们大多数功能都是一直在修改 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) 是降低对象之间直接依赖关系的一种有效方式,这种方式可以减少直接依赖,将依赖变为间接依赖。

fichier sans nom (1).png

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

Dans notre fonction de téléchargement vidéo v5, l'uploader doit être injecté via l'injection de dépendances pour un meilleur test :

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

Ce qui précède est un pseudocode, pas une implémentation existante, ce qui serait mieux du point de vue de la testabilité.

Résumer

Cet article présente d'abord la différence entre les tests basés sur l'interaction et les tests basés sur l'état, comment choisir les tests d'interaction et les tests basés sur l'état, et comment utiliser la pseudo-implémentation pour tester des scénarios fonctionnels difficiles à contrôler. directives pour écrire du code testable. De cela nous pouvons tirer les conclusions suivantes :

  • Qu'ils soient basés sur des tests d'état ou basés sur des tests d'interaction, ils ont tous leurs propres avantages et leurs propres scénarios applicables, donc lors de l'écriture des tests, nous devons les utiliser en combinaison pour jouer parfaitement leurs rôles respectifs ;
  • Pour les fonctions complexes, si un grand nombre d'états doivent être simulés, et que c'est difficile à simuler, on peut utiliser la pseudo-implémentation pour nous aider à tester, notamment pour certaines méthodes qui s'appuient sur des services tiers ;
  • Comment écrire du code testable est un test de la capacité personnelle du développeur et de sa compréhension des tests. Bien que trois lignes directrices pour la conception de code soient données ici, il est toujours possible d'écrire du code difficile à tester dans le processus de conception du code et de sa mise en œuvre. De ce point de vue, le mode développement de TDD nous aide à écrire du code testable, et le mode testing->coding->refactoring nous aide à écrire du code de haute qualité.

Référence

Je suppose que tu aimes

Origine juejin.im/post/7079951448812814349
conseillé
Classement