preface

Unit testing is an indispensable part of excellent software. High-quality unit testing and certain test coverage are important standards to measure whether an open source project is qualified. Software without any test will not be dare to use.

V5 is a transformative version of wangEditor, a product that is closer to modern Web development patterns in terms of underlying selection, architectural design, and engineering. For a more detailed look at our V5 architecture, see our previous article: Designing a Rich text Editor based on Slate.js (without React).

The author is mainly responsible for the unit test, E2E test, CI & CD and other work in the project. Since last October, the author has designed the automatic test of the whole project and participated in the writing of the unit test in the whole project. Due to work reasons, the investment time is also intermittent, until these two weeks can be regarded as a certain progress. For the following reasons:

  • Because of its particularity, the rich text scenario brings some difficulties to unit testing. The selection, cursor position and other data have certain mock difficulties in the testing process.
  • For such a complex menu function as a table, the amount of code and design of its own determine the difficulty of single test to a certain extent;
  • Of course, for a complete rich text editor, there are many functions, in addition to the common menu functions, upload functions, plug-ins and so on, which to some extent bring costs to the single test.

The progress so far is:

  • The unit test has basically covered all menu functions, and the code coverage of almost all menus is above 90%.
  • The core uploading module function has been basically covered, while the overall coverage of other core package functions is still relatively low, which is also the reason why the overall coverage is not very high.

Although a good software can not be measured solely by code coverage, after all, V5 is a version of V4, and v4 still has a certain number of users, so we accumulated a large number of user stories and logged over 4000+ pits. In V5 we basically covered all the potholes we stepped on. Having said that, we want some code test coverage to ensure the quality of the editor and the quality of the code as we develop the project.

Here’s a summary of some of my thoughts on writing V5 unit tests in recent months.

Based on interaction testing or state testing

Those of you who are familiar with unit testing know that there are two common approaches to verifying the expected behavior of code in unit testing: 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 interacts with the object it collaborates with in the way we expect it to, rather than verifying that the object’s final state matches.

Interaction testing or state testing? I had the same problem writing unit tests for V5. From a macro architecture perspective, our editor is developed based on slate.js MVC architecture. Therefore, we should pay more attention to the content of the editor after code execution, which is the state mentioned above. Consider the following example:

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

    // Select a custom video and upload it
    if (customBrowseAndUpload) {
      customBrowseAndUpload(src= > insertVideo(editor, src))
      return
    }

    // Set the type of the selected file
    let acceptAttr = ' '
    if (allowedFileTypes.length > 0) {
      acceptAttr = `accept="${allowedFileTypes.join(', ')}"`
    }

    // Add file input (re-create input each time)
    const $body = $('body')
    const $inputFile = $(`<input type="file" ${acceptAttr} multiple/>`)
    $inputFile.hide()
    $body.append($inputFile)
    $inputFile.click()
    // Select the file
    $inputFile.on('change'.() = > {
      const files = ($inputFile[0] as HTMLInputElement).files
      uploadVideo(editor, files) // Upload the file})}Copy the code

This is one of the menu functions that we insert into the video. In this case, when I was writing the unit test, I was actually more interested in whether the core method uploadVideo was called after the input change event was triggered, and this method calls the basic method of uploading a module in the core module, This is a typical scenario suitable for using interactive testing, not just whether there is a new video node in the uploaded video and edited content.

Generally, interactive testing is more complicated than state-based testing, because interactive testing needs to introduce certain simulated objects. But state-based testing sometimes doesn’t do exactly what we want it to do, because when code is functionally complex, we tend to focus on interactions between many different objects rather than simple state changes.

So don’t worry too much about using interaction tests or state tests, they’re both very useful, and the combination covers all the points in your code that need to be tested.

How to use fake implementation well

Not all code is simply input -> output, and if it were, the world of code testing would be very simple. Especially for scenarios like rich text, it’s easy to overlook the fact that most of our functionality is always modifying the content in the Editor object, just looking at the code. I made the same mistake when WRITING V5 unit tests.

So how to treat such code testing scenarios correctly, especially for very complex scenarios, it is very difficult to simulate even some states of a function working correctly. Look at this example:

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

    // If there is only one column, delete the entire table
    const rowNode = DomEditor.getParentNode(editor, selectedCellNode)
    constcolLength = rowNode? .children.length ||0
    if(! rowNode || colLength <=1) {
      Transforms.removeNodes(editor, { mode: 'highest' }) // Delete the entire table
      return
    }

    / / -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- not only 1 column, continued to -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --

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

    // Iterate over all rows and delete cells one by one
    const rows = tableNode.children || []
    rows.forEach(row= > {
      if(! Element.isElement(row))return

      const cells = row.children || []
      // Iterate over all cells of a row
      cells.forEach((cell: Node) = > {
        const path = DomEditor.findPath(editor, cell)
        if (
          path.length === selectedCellPath.length &&
          isEqual(path.slice(-1), selectedCellPath.slice(-1)) // Two arrays with the same last digit
        ) {
          // If the path of the current TD and the path of the selected TD have the same last digit, they are in the same column
          // Delete the current cell
          Transforms.removeNodes(editor, { at: path })
        }
      })
    })
Copy the code

For those of you who have not looked at the source code of our project in detail, though you may not be able to fully understand this code, let me briefly explain the functions of this code.

The first thing to be clear about is that this is actually a menu function that deletes table columns. Although the function is just a sentence, there is a lot of state and logic behind it that needs to be checked:

  • First, if the menu is disabled, it returns directly.
  • Then check whether the current selection element contains the most fine-grained table cell node. If not, it is not the table content and returns directly. If so, find the parent element of the cell, which is the table row node;
  • Then if there is no parent element (indicating that the table dom structure is abnormal) or only one row of the current table, delete the entire table (special business logic);
  • The last step is to iterate through all the cells and delete the corresponding cells.

The result of this function is to delete a column or an entire table, but the entire code execution process is also important. When testing this feature, you should ensure that the code executes the appropriate logic in the corresponding object state. The hardest thing to do in testing this scenario is to construct different object states. You need to simulate all the scenarios, and you will find this very difficult.

Don’t worry, it’s time for the powerful pseudo implementation of single test. At this point, we can replace some of the implementations in our code by constructing stub objects that return the desired result. Without further ado, here’s an example:

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()

    // Replace the isDisabled implementation with a pseudo-implementation
    jest.spyOn(deleteColMenu, 'isDisabled').mockImplementation(() = > false)
    // Replace the getParentNode implementation with a pseudo-implementation
    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 for final test assertions
    const removeNodesFn = jest.fn()
    jest.spyOn(slate.Transforms, 'removeNodes').mockImplementation(removeNodesFn)

    deleteColMenu.exec(editor, ' ')
    // This assertion ends with the method we expect to be called
    expect(removeNodesFn).toBeCalled()
  })
Copy the code

While the pseudo-implementation is very powerful, we always recommend that we only consider using this technique for testing when the object state is very difficult to simulate. In particular, when testing code that relies on third-party libraries or services, it’s really hard to control their state, so you can implement a pseudo-implementation for testing.

The concepts of pseudo-implementation and stub are mentioned above. If you are not familiar with these two concepts, please check out my previous article: Jest simulation in practice in wangEditor, which also describes how to implement powerful simulation techniques using Jest.

Of course, the above code, except for the fact that it is inherently difficult to simulate state due to scenario problems, makes it difficult to write unit tests. In fact, from a code design point of view, the code abstraction is too scattered, which is one of the reasons why it is difficult to test. So here are a few principles for designing testable code.

Criteria for testable code

1. Use composition rather than inheritance

How you build complex objects from individual functions has a big impact on the testability of your code. In object-oriented languages, this kind of functionality is typically implemented through inheritance and composition.

Inheritance is a more traditional reuse function, and while it works up to a point, inheritance has a negative impact on the testability, maintainability, and complexity of your design. Once a subclass inherits from a parent class, it must accept all the functionality that the parent class has, and when extending the functionality of the parent class, even if the subclass does not need this functionality, it must accept it unconditionally.

In our V5 design, some menus inherit from BaseMenu, so it is inevitable to introduce unnecessary functions for some menus:

abstract class BaseMenu implements IDropPanelMenu {
  abstract readonly title: string
  abstract readonly iconSvg: string
  readonly tag = 'button'
  readonly showDropPanel = true // Click button to display dropPanel
  protected abstract readonly mark: string
  private $content: Dom7Array | null = null

  exec(editor: IDomEditor, value: string | boolean) {
    // No additional code needs to be executed until the droPanel pops up when the menu is clicked
    // Leave it blank
  }

  getValue(editor: IDomEditor): string | boolean {
    // omit the code
  }

  isActive(editor: IDomEditor): boolean {
    // omit the code
  }

  isDisabled(editor: IDomEditor): boolean {
    // omit the code
  }

  getPanelContentElem(editor: IDomEditor): DOMElement {
    // omit the code}}// Useless exec behavior
class BgColorMenu extends BaseMenu {
  readonly title = t('color.bgColor')
  readonly iconSvg = BG_COLOR_SVG
  readonly mark = 'bgColor'
}
Copy the code

By abstracting various behaviors into more concrete classes or interfaces, the functions of classes can be reused more flexibly through composition.

2. Isolate dependencies

To make it easier to replace dependencies with a test surrogate, it is critical to isolate dependencies to make them easier to replace. Especially if our project relies on some implementation for third parties, we should try to isolate third party dependencies.

In Refactoring and Improving Design of Existing Code, the authors write about the concept of seams: the points at which a system can change its behavior without changing code that directly affects its behavior. In layman’s terms, the point at which you can replace one piece of code with another during testing without modifying the code to be tested is called a seam. Here’s an example:

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
    // It is convenient to test the inline Child scenario and modify the implementation of 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'}],})})Copy the code

JavaScript as an “object oriented” language, everything is an object, we can directly modify the behavior of objects.

3. Inject dependencies

Dependency Injection (DI) is an effective way to reduce direct dependencies between objects. It can reduce direct dependencies and turn dependencies into indirect dependencies.

There are two general approaches to dependency injection. One is setter-base, where the dependency is injected by calling setter methods. The other is field-setters, which inject dependencies by passing arguments to the constructor when the instance is initialized.

In our V5 video upload function, uplader 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 () {
    // Use uploader here for uploading}}Copy the code

The above is pseudocode, not the existing implementation, which would be much better from a testability perspective.

conclusion

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

  • Whether based on state testing or interaction testing, they all have their own advantages and applicable scenarios, so we should use them in combination when writing tests to give full play to 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. While there are three guidelines for designing code, it is still possible to write code that is difficult to test during the actual design and implementation process. From this perspective, TDD’s development pattern helps us write testable code, and the test-> code-> refactoring pattern helps us write high code quality.

Reference

  • The art of test-driven development
  • wangEditor-v5