preface

WangEditor is an excellent domestic rich text editor, which is favored by many domestic users because of its simple API design. At present, we have three QQ groups with more than one thousand users, the number of Github stars has reached 11K, and the weekly downloads on NPM are around 10K. In the domestic open source projects, Especially in the field of rich text editors, it has been a very good achievement.

In 2020, we upgraded version V4 and rewrote it in Typescript. This time, we formed an open source team to develop each module of the editor. I joined the wangEditor development team in October 2020, when the core features were almost complete. After joining the team, I was mainly responsible for the test of the project, including unit test (hereinafter referred to as single test) and E2E test, and ensured the quality of the project code by improving the coverage and quality of single test.

Since the V4 upgrade, we have missed a lot of individual tests, which, to be honest, we have not done very well. When I first joined the team, the single test coverage rate was about 65% at its highest, and then it declined to 62% at its lowest point. To be honest, as a good open source project, this number of single-test coverage is certainly a failure, but then again, the rich text scenario presents a huge challenge for unit testing due to its API specificity and interaction dependence.

What’s hard about rich-text editor unit testing

First, if you have seen the source code of our Editor, you should know that our core modules, such as Editor, Text, History, Selection, Menu, etc., are all designed in the way of class. Moreover, as the dependencies of Editor, they frequently interact with the Editor to drive the function of the Editor. As we know, one of the most complex test scenarios in unit testing is the interaction between objects. Our entire design is predestined to make our single test not easy, and of course this design is very helpful for reuse and division between modules.

Second, the classic product presentation mode of the editor is the toolbar and the edit area. Editing areas use Contenteditable to enable the container to be edited. Menus and editing areas interact with each other through user actions such as bold, align styles, title styles, code blocks, references, and insert images. This makes much of the editor’s functional testing dependent on user interactions, such as click, mouse out and in, keyboard, scroll, copy and paste, and so on.

Third, many native browser apis in Jest are not supported, such as the core API of our editor: Document. execCommand, Clipboard Event, etc. Other core object Selection, even if supported, is difficult to simulate user Selection in Jest, which makes unit testing difficult.

In addition, there are upload image functions involving Ajax, File and other objects. We know that single test can’t have real dependencies. You can’t have image upload functions that rely on real API services. If you use real dependencies, you’re not doing unit tests, you’re doing integration tests.

How does wangEditor solve these problems

When I first accepted the unit test of the project, I was worried about it, so I repeated the documents on Jest’s official website and bought books on learning single test to store up knowledge. Whether it’s Object interaction, functionality that relies on user interaction, or Ajax testing, there’s nothing Fake objects can’t solve, and for objects that we can’t control in testing, we can just replace them with Fake objects.

Here is a brief introduction to the important knowledge of unit testing, Fake Object technology. Mock objects and stub objects are the two most common types of mock objects used in a single test. When doing object interaction testing, we need to use both of these objects frequently. So what’s the difference between them?

Mock objects are pseudo-objects in the test system that can influence the results of unit tests by verifying that the objects under test are called the way we expect them to be, so we usually assert mock objects during tests. I’ll talk more about that when I get to specific examples.

Stub objects are also a type of pseudo object, but stub objects only act as “standing posts” to assist us in testing, and we do not end up asserting stub objects.

The following diagram can help us understand:

In wangEditor, we make extensive use of both techniques to assist in unit testing.

Simulation techniques in Jest

The test framework we are using is Jest, and to prepare for subsequent introductions, we are going to generalize a little bit about simulation techniques in Jest. There is no distinction between mock objects and stub objects in Jest, and there is no API to distinguish mock or stub objects. It depends on how you use the objects created by the Jest API in your tests.

Simulation function

Simulating functions in Jest is very simple. Here are a few examples:

const fn = jest.fn()
const fn1 = jest.fn().mockImplementation(() = > 'fn1')
const fn2 = jest.fn().mockReturnValue(true)
Copy the code

These are common ways of simulating functions.

Simulation ES6 class

In addition to emulating functions, Jest can also emulate classes in ES6:

import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); 

it('We can check if the consumer called the class constructor'.() = > {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
Copy the code

Of course, you can also simulate class-specific methods:

import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player'.() = > {
  return jest.fn().mockImplementation(() = > {
    return {playSoundFile: mockPlaySoundFile};
  });
});
Copy the code

Here are some examples of Jest’s website, but I’m not going to go into details here, but if you’re interested, you can go to Jest’s website to learn more.

A method that simulates an object

There is a very useful API in Jest: Jest. SpyOn, which I often use in projects. Here is an example from our project:

const liParent = $('<ul></ul>')
const li = $('<li></li>')
liParent.append(li)

jest.spyOn(editor.selection, 'getSelectionContainerElem').mockReturnValue(li)
Copy the code

Use of simulation techniques in wangEditor

As mentioned earlier, there are a number of test scenarios in wangEditor that involve object interaction, so creating mock objects or stub objects of any kind is essential. Now, let’s introduce them one by one.

To simulate the dcoument. ExecCommand

As mentioned earlier, the native Document. execCommand API is not supported in Jest, but we have a number of test scenarios that rely on the API, so we modeled this function:

export default function mockCommand (document: Document) {
    document.execCommand = jest.fn()
    document.queryCommandValue = jest.fn()
    document.queryCommandState = jest.fn()
    document.queryCommandSupported = jest.fn().mockReturnValue(true)}Copy the code

Use in tests:

import mockCommand from '.. /.. /helpers/command-mock'

test('Calling createAction creates a table with specified rows and columns'.() = > {

    mockCommand(document)
    
    const editor = createEditor(document.`div1`)
    const createTableInstance = new CreateTable(editor)
    createTableInstance.createAction(2.1)
    
    expect(document.execCommand).toBeCalledWith(
    	'insertHTML'.false.`
      



`
)})Copy the code

To simulate the XHR

When testing the image upload module, we needed to rely on Ajax, but we didn’t use a real API service, so we had to manually invoke Ajax methods in a simulated way. I first wrapped a xhrMockClass:

const xhrMockClass = (config: any) = > ({
    open: jest.fn(),
    send: jest.fn(),
    setRequestHeader: jest.fn(),
    ontimeout: jest.fn(),
    upload: jest.fn(),
    onreadystatechange: jest.fn(),
    status: config.status,
    readyState: 4.responseText: config.res,
})

export default xhrMockClass
Copy the code

With some parameters, the Ajax returns are controlled to simulate various scenarios for the purpose of testing, depending on the specific use in the test:

test('Failed to call uploadImg to upload image, support onError hook listener'.done= > {
        expect.assertions(1)

        const errorFn = jest.fn()
        const alertFn = jest.fn()

        const upload = createUploadImgInstance({
            uploadImgServer,
            uploadImgHooks: {
                error: errorFn,
            },
            customAlert: alertFn,
        })
        const files = createMockFiles()

        const mockXHRObject = mockXHRHttpRequest({ status: 500 })

        upload.uploadImg(files)

        mockXHRObject.onreadystatechange()

        setTimeout(() = > {
            expect(errorFn).toBeCalled()
            done()
        })
    })
Copy the code

Some may wonder if the onReadyStatechange call before the setTimeout assertion is an artificial Ajax method. What’s the point. First, you need to understand that in the above test scenario, you are more concerned with the error handling hooks you defined when Ajax returns an error. You don’t need to care how Ajax happens, whether it’s our manual simulation that makes it happen or whether it’s returned by a real API service, it doesn’t affect our testing.

In this test, we also simulated two functions, the onError hook we focused on and the Alert. Because when an error occurs, our code handles both the onError hook and the alert for the error message. If you do not pass this alert, the default call is the native window.alert, which is not supported by the API in Jest, and the test will fail with an error. To recall the differences between mock objects and stub objects, here we assert whether or not the error callback hook was executed, so alertFn is the stub object here and errorFn is the mock object that our tests focused on.

Simulating user interaction

As we mentioned earlier, rich text is also more difficult to measure because there are many scenarios that rely on user interaction. Some menus are designed to expose the clickHandler public API, so you can test them by calling clickHandler directly. But in some cases, we can’t test the code in the click callback without calling it directly. In the case of the Click interaction, the corresponding callback can be triggered as long as we find the corresponding element through dom manipulation and call its click method. However, some scenarios, such as our pull-down, require a mouseEnter event, for which there is no way to trigger the event even if we locate the element.

After investigation, it is found that dom elements have a native dispatchEvent method that can actively trigger most mouse and keyboard events. So I’ve wrapped the dispatchEvent method to simulate some user interaction events:

import { DomElement } from '.. /.. /src/utils/dom-core'

const EventType = {
    Event: Event,
    KeyBoardEvent: KeyboardEvent,
    MouseEvent: MouseEvent,
    // Jest has no ClipboardEvent, uses Event instead
    ClipboardEvent: Event,
}

type EventTypeKey = keyof typeof EventType

export default function mockEventTrigger(
    $el: DomElement,
    type: string,
    eventType: EventTypeKey = 'Event', option? :any
) {
    const EventConstruct = EventType[eventType]

    const event = new EventConstruct(type, {
        view: window.bubbles: true.cancelable: true. option, }) $el.elems[0].dispatchEvent(event)
}
Copy the code

Apply dispatchEvent to the test:

test('After the editor is initialized, the editor area is bound with the Enter key keyup event, which triggers the function execution of eventsHook enterUpEvents'.() = > {
        const mockClickFn = jest.fn()

        Object.defineProperty(editor.txt.eventHooks, 'enterUpEvents', {
            value: [mockClickFn, mockClickFn],
        })

        dispatchEvent(editor.$textElem, 'keyup'.'KeyBoardEvent', {
            keyCode: 13,})// Simulate the non-Enter case
        dispatchEvent(editor.$textElem, 'keyup'.'KeyBoardEvent', {
            keyCode: 0,
        })

        expect(mockClickFn.mock.calls.length).toEqual(2)})Copy the code

In the example above, we hijack the value of enterUpEvents in the event hook and use the Jest impersonator function instead. The Enter KeyBoardEvent event and other non-Enter events are emitted, and the mock function is asserted to have been executed twice instead of four times.

The use of encapsulated DispatchEvents allows a single test to cover almost all mouse and keyboard event scenarios, and you no longer have to worry about writing a single test for scenarios that rely on user interaction. Of course, there are still some events that cannot be simulated at present. I do not know whether I have not found the right way, such as the rolling event. I have tried many ways, but failed to simulate successfully.

other

In addition to the above mentioned, we also simulated some other objects in the test, such as simulation files, simulation of different browsers, etc. Due to limited space, I will not introduce them here. Interested partners can directly go to our warehouse test directory: wangEditor.

It should also be added that the two apis heavily used in our project to simulate methods or Object values in objects are jest. SpyOn and Object.defineProperty. For jest. SpyOn, I often use it to simulate stub objects (mostly object methods). As I mentioned at the beginning, a lot of the functionality of wangEditor depends on the selection, and then you can manipulate the selection, or you can manipulate the elements in the selection. However, it’s hard to really rely on the selection to test in Jest, so you need to use stub objects instead. The Selection object we encapsulate is a collection of selection operations and reads, and as a dependency of the Editor, we can easily test it in most cases by simply replacing its concrete method implementation. Here’s an example:

test('Once the editor is initialized, the editor area will bind the mouseup mouseDown event, which will process the range. If the range does not exist, it will not be processed.'.() = > {
    const saveRangeFn = jest.fn()
    const getRangeFn = jest.fn(() = > null)
    jest.spyOn(editor.selection, 'saveRange').mockImplementation(saveRangeFn)
    jest.spyOn(editor.selection, 'getRange').mockImplementation(getRangeFn)

    dispatchEvent(editor.$textElem, 'mousedown'.'MouseEvent')
    dispatchEvent(editor.$textElem, 'mouseup'.'MouseEvent')

    expect(saveRangeFn).not.toBeCalled()
})
Copy the code

In the example above, we test that clicking on the edit area saves the current range. If the current range object is null, the saveRange method is not executed. We created the mock function saveRangeFn and the stub function getRangeFn by spyOn, respectively, to make testing easy. Of course, in addition to using the selection object selection object, in some other similar scenarios I also often through the API object method simulation, give me the experience is very simple to use.

SpyOn is generally used to hijack methods on objects. After Jest 22.1.0, of course, you can use methods to hijack getters for properties of objects, but the getters must be your own custom getters on the object. You cannot hijack the default getters of objects. It is still not suitable for simulating the properties of objects. So I usually use Object.defineProperty when emulating Object attributes. This API is familiar to anyone who is familiar with the responsivity principle of Vue2.0. It is commonly used to hijack getters and setters for properties of objects, and I often use it in some test scenarios. Here’s an example:

const imgUrl = 'http://www.wangeditor.com/imgs/logo.jpeg'
const errorUrl = 'error.jpeg'

 Object.defineProperty(global.Image.prototype, 'src', {
        // Define the property setter
        set(src) {
            if (src === errorUrl) {
                // Call with setTimeout to simulate async loading
                setTimeout(() = > this.onerror(new Error('mocked error')))}else if (src === imgUrl) {
                setTimeout(() = > this.onload())
            }
        },
    })
 
 test('insertImg can insert images into the web editor, insert images failed to load can be configured with customAlert error message'.done= > {
        expect.assertions(1)

        const alertFn = jest.fn()

        const uploadImg = createUploadImgInstance({ customAlert: alertFn })

        mockSupportCommand()

        uploadImg.insertImg(errorUrl)

        setTimeout(() = > {
            expect(alertFn).toBeCalledWith(
                'Insert picture error'.'error'.'wangEditor: Error inserting picture, picture link'${errorUrl}", download link failed '
            )
            done()
        }, 200)})Copy the code

The above example is interesting because in Jest we can’t actually trigger the Image’s error and load events, so I hijacked the SRC method of the Image Object using Object.defineProperty. According to different SRC values, the error or load events of the image can be actively triggered, so that the error and load can be triggered, so that our test can be successful.

Through the examples above, I have also come to the conclusion that there is almost nothing that cannot be simulated in unit testing, and as long as you fully understand and use various simulation techniques, more than 90% of the testing problems can be solved.

conclusion

After a period of unit test optimization, our test coverage increased from 62% to 82%. I shared unit testing practice online with the team, and everyone had a new understanding and understanding of unit testing, and became serious about unit testing code, which is also the result I want to see. In my opinion, one of the most important criteria for judging the quality of a project is the coverage of testing. No one wants to use an open source project without testing.

This article introduces the application of simulation techniques in wangEditor from the perspectives of simulating Document. execCommand, simulating XHR, simulating user interaction, and using the Jest. SpyOn and Object.defineProperty APIS. Still achieved good results. Of course, there are some practices that I can’t say are necessarily best practices, but it does address our current dilemma of low test coverage.

Looking ahead, we can do a better job of combining unit testing with E2E testing, complementing each other in wangEditor’s quality protection network. There is still a lot of room for improvement in some of the tests. In addition to some of the earlier functional module test coverage was not ideal, some of the use cases did not meet the standards of good unit testing. All of these will be optimized in future refactoring.