Back
Jul 21, 2022

Codemirror: unit-testing codemirror react components

One of our recent projects includes the functionality of an inline code editor. This code editor needed to be highly extensible and have custom features. To address this, we chose Codemirror v6 due to its peculiar architecture - it is highly customizable, and all the additional features are provided into codemirror engine as Extension objects.

To integrate it into a React application, we used Rodemirror. It is very flexible, allowing us to simply pass CodeMirror extensions from the outer environment (e.g. components higher up in the hierarchy).

Because of these libraries, instantiating a code editor is almost as simple as a regular controlled input:

export const BaseCodeEditor: FunctionComponent = ({value, onUpdate, extensions, ...rest}) => (
  
);

This basic codemirror component can be used by more components that represent some business logic. Something like this:

export const PythonCodeEditor: FunctionComponent = (props) => {
  // https://github.com/codemirror/lang-python
  const pythonLanguageExtensions = useMemo(() => [pythonLanguage], [])
  return 
}

And there can be dozens of components like PythonCodeEditor, because there may be different places in your system that require different business logic. This way of structuring the code seemed quite convenient to us. However, we had to face a serious challenge. 

Testing

There are several ways to test React code. First, we tried using Jest, assuming that we can test codemirror just like we would any ordinary html input. However, here we bumped into an issue. Under the hood, codemirror is not an html input.

image-20220705-103022.png

As you can see, the DOM node that first-hand contains content typed by user, is

...
. Why is this important? This is because Jest is powered with jsdom as its default backend to emulate dom behavior, and it has some limitations. Such as, it does not support contenteditable elements. Therefore, you will not be able to test codemirror react components just as any input - you will have to find some other way. We did our research and here are the options we considered.

Testing codemirror without React

As you may have noticed, codemirror is actually framework agnostic and has no attachments to any component-rendering library. So, the most minimalistic option might be to test codemirror as a set of javascript classes and functions. You could even reference codemirror repositories to check how to write tests as intended.

However, there is a large con that prevented us from using this approach: we had several very specific code editor components for different purposes. They all had some customization that could be applied, but still, a lot of logic was associated directly with the component. We still could test separate extensions, but they did not feel complete until the entire code block was executed, as it would be in a real runtime. Thus, we did not use this approach. However, if your use case will include a single entirely customizable component, testing only a codemirror class and its settings is a solid option.

End-to-end tests

Adding some Selenium/Cypress suites was the easiest way to cover codemirror with tests, and it is quite normal to have some kind of smoke tests for the bare minimum of features. But you certainly do not want to cover all corner-cases of your codemirror code with e2e-tests. This will bloat run time of your CI to hours. So, even though we had some e2e tests over codemirror, we kept looking for a more efficient way to test it.

Alternative environment for jest

One possible solution is to replace jsdom with a more sophisticated alternative like Jest-Puppeteer. Potentially this allows to bypass limitations that jsdom has. However, we haven't explored this option too much for the following reasons:

Despite being launched using the same runner, the test syntax is dramatically different. Does it make sense to use the same tool if it is used in a completely unfamiliar way?

Some libraries may not allow you to easily swap test environments. For instance, if your project is powered by react-scripts, it will not just let you reconfigure: react-scripts will notify you that this is the only intended way to eject your project out of react-scripts infrastructure. Thus, it seemed strongly unintentional.

Potentially it might slow down the performance of jest tests, since jsdom is way lighter than puppeteer.

The hybrid option we are using

The option described below, we found quite effective. We are currently using it and found no restrictions on what codemirror features we could test. In order to understand what exactly we are doing, I have to explain how Rodemirror organized its component. Basically, it contains a reference to Javascript object instance that represents codemirror. This view object is the main instance that encompasses all the codemirror logic. It accepts state, reference to DOM node and (indirectly) list of our custom extensions. How it looks in unchanged Rodemirror code:

image-20220705-120750.png

If we go back to tests written inside codemirror repos, you will notice the majority of manipulations are conducted with this view instance. One peculiar thing about this is that the content is manually set inside the DOM node, then manually applied to trigger codemirror change detection, and this will serve as an emulation of user input. The only thing that prevents us from doing this is that view is an object available only inside CodeMirror component. So, how could we make use of it for our cases? Here is what we came up with:

export type CodeMirrorProps = {
  // some other props
  onEditorViewInit?: (editorView: EditorView) => void;
};

const CodeMirror = forwardRef<HTMLDivElement, CodeMirrorProps>(
  ({ onEditorViewInit, ...rest }, ref) => {
    const editorView = useRef<EditorView>();
    // something else...

    useEffect(() => {
      const state = EditorState.create({
        doc: value,
        selection: selection,
        extensions,
      });

      if (onEditorStateChange) onEditorStateChange(state);
      
      const editorView.current = new EditorView({
        parent: innerRef.current!,
        state,
      })
      if (onEditorViewInit) {
        onEditorViewInit(editorView.current)
      }

      return () => editorView.current.destroy();
    }, [innerRef]);

    // something else...
  },
);

We introduce a new property - onEditorViewInit. So, when it is time to initialize view, we are able to store the reference to it somewhere outside. Also, this will not affect the production code.

And here is how it could be used:

test("CodeMirror should run onUpdate when content is changed", () => {
  let editor!: EditorView;
  const handleUpdate = jest.fn();
  const { queryByText } = render(
    <CodeMirror
      value="foo"
      onEditorViewInit={(editorView) => {
        editor = editorView;
      }}
      onUpdate={(update) => {
        if (update.docChanged) {
          handleUpdate(update.state.doc.toString());
        }
      }}
    />
  );

  editor.domAtPos(1).node.nodeValue = "froo";
  // @ts-expect-error
  editor.observer.flush();

  expect(editor.state.doc.toString()).toBe("froo");
  expect(queryByText("foo")).not.toBeInTheDocument();
  expect(queryByText("froo")).toBeInTheDocument();
  expect(handleUpdate).toHaveBeenCalledWith("froo");
});

Here we can trigger change detection after we manually put the value into codemirror DOM element. However, this has some limitations: you just “dump“ all the text at once, like putting clipboard to input. For some cases you need to emulate input one key per user action:

test("AutocompleteCodeMirror should run autocomplete for if value is updated", async () => {
  let editor!: EditorView;
  const handleAutocomplete = jest.fn(
    (request: AutocompleteRequest): Promise<AutocompleteResponse> => {
      if (request.expression === "prin") {
        return Promise.resolve({
          result: [{ label: "print", type: "function" }],
        });
      }
      return Promise.resolve({ result: [] });
    }
  );
  renderComponent({
    value: "=",
    onEditorViewInit: (editorView) => {
      editor = editorView;
    },
    getAutocomplete: handleAutocomplete,
  });

  // global functions
  await act(async () => {
    type(editor, "prin");
    await new Promise((r) => setTimeout(r, 100));
  });

  expect(handleAutocomplete).toHaveBeenCalledWith({
    expression: "prin",
    cursorPosition: 4,
  });
  expect(
    document.getElementsByClassName("cm-tooltip-autocomplete")[0]
  ).toBeInTheDocument();
});

export const type = (view: EditorView, text: string) => {
  let cur = view.state.selection.main.head;

  [...text].forEach((char) => {
    view.dispatch({
      changes: { from: cur, insert: char },
      selection: { anchor: cur + char.length },
      userEvent: "input.type",
    });
  });
};

Here we can trigger autocomplete which listens for each key input and runs http call. AutocompleteCodeMirror is a custom component that implements autocomplete integration with codemirror and accepts a handler function that provides autocomplete values to codemirror instance.

This allows you to take any component that uses codemirror and cover it with tests not as a javascript class, but as a React component - exactly as it will be provided to your end user.

Conclusion

Covering your code with fast and comprehensive tests is just as important as writing clean and understandable code. Therefore, it's a good idea to take the time to find ways to test your features, even if it seems impossible at first.

I hope the story we shared will help you solve your unit testing problems, or at least inspire you to find ways to improve your code coverage with tests. Happy coding!

Subscribe for the news and updates

More thoughts
Apr 19, 2022Technology
Improve efficiency of your SELECT queries

SQL is a fairly complicated language with a steep learning curve. For a large number of people who make use of SQL, learning to apply it efficiently takes lots of trials and errors. Here are some tips on how you can make your SELECT queries better. The majority of tips should be applicable to any relational database management system, but the terminology and exact namings will be taken from PostgreSQL.

May 26, 2017Technology
Tutorial: Django User Registration and Authentication

In this beginners friends article I'll explain how to make authentication with Google account on your Django site and how to make authentication for you REST API.

Mar 12, 2017Technology
Creating a chat with Django Channels

Nowadays, when every second large company has developed its own instant messenger, in the era of iMessages, Slack, Hipchat, Messager, Google Allo, Zulip and others, I will tell you how to keep up with the trend and write your own chat, using django-channels 0.17.3, django 1.10.x, python 3.5.x.

Aug 8, 2016TechnologyBusiness
How To Add HTML5 Geolocation To Your Web App?

In this article I will describe how to integrate geolocation HTML5 function to a web app so you can then easily implement it in your apps or websites. As an example we are going to create small web app which will be able to calculate the shortest route between detected user’s location and predefined destination using Google Maps API.

May 12, 2010Technology
Twitter API, OAuth and decorators

In my current project I had a task to use twitter API. Twitter uses OAuth for authentication, which is pretty dreary. To avoid fiddling with it all the time, I've moved authentication to decorator. If key is available - nothing happens, just view is launched as usual. It's convenient that there's no need for additional twitter settings in user profile. Code is in article.

Feb 18, 2010Technology
Absolute urls in models

Everybody knows about permalink, but it's usually used only in get_absolute_url. I prefer to use it for all related model urls.class Event(models.Model):# ...@models.permalinkdef edit_url(self):return ('event_edit', (self.pk, ))And then in template:<a href="{{ event.edit_url }}">Редактировать событие</a>