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<BaseCodeEditorProps> = ({value, onUpdate, extensions, ...rest}) => (
  <CodeMirror
    value={value}
    onUpdate={onUpdate}
    // custom keymap, tab size, closing brackets rules go here.
    // More: https://codemirror.net/docs/extensions/
    extensions={extensions}
    {...rest}
  />
);

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

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

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 <div contenteditable="true">...</div>. 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 18, 2023Technology
TDD guide for busy people, with example

This quick introduction to TDD is meant to show the implementation of this practice with a real-life example.

Nov 29, 2022Technology
React Performance Testing with Jest

One of the key requirements for modern UI is being performant. No matter how beautiful your app looks and what killer features it offers, it will frustrate your users if it clangs.

Sep 1, 2021TechnologyBusiness
Top 10 Web Development Frameworks in 2021 - 2022

We have reviewed the top web frameworks for server and client-side development and compared their pros and cons. Find out which one can be a great fit for your next project.

Sep 21, 2020Technology
How to Optimize Django ORM Queries

Django ORM is a very abstract and flexible API. But if you do not know exactly how it works, you will likely end up with slow and heavy views, if you have not already. So, this article provides practical solutions to N+1 and high loading time issues. For clarity, I will create a simple view that demonstrates common ORM query problems and shows frequently used practices.

Mar 2, 2017Technology
API versioning with django rest framework?

We often handling API server updates including backwards-incompatible changes when upgrading web applications. At the same time we update the client part, therefore, we did not experience any particular difficulties.

Feb 28, 2017Technology
How to write an API in Django

There is such a term as Remote Procedure Call (RPC). In other words, by using this technology, programs can call functions on remote computers. There are many ways to implement RPC.