Extending JSDom's HTMLElement for Jest Testing

english mobile

Tape measure extended diagonally across a barren landscape

If you're unit testing your visual JavaScript/HTML components using jsdom for DOM rendering, sooner or later you will encounter the unimplemented parts of the library. If you're not sure if you're using jsdom, check your project's package.json for "jsdom" in "dependencies" or "devDependencies". Most projects set up to use jest also use jsdom if they're testing the rendering of html elements.

I'm going to focus on extending HTMLElement, but, as the jsdom docs state, you can use these principles to fill in any functionality you're missing. I first encountered this issue while trying to implement automatic scrolling to a specific element, so let's start there. Say, for example, that your component calls scrollIntoView() on itself or a child component.

As soon as you call the method in your component that includes scrollIntoView(), you will get an error in your tests (the specific error will depend on what JavaScript framework, if any, you're using). At the very least, you need to provide an implementation of scrollIntoView() to get rid of the error. And if that's all you need, look no further than this Stack Overflow answer. In a nutshell, you add a scrollIntoView property to HTMLElement and assign a jest mock function to that property.

But what if you need to know if scrollIntoView was called from a specific HTMLElement? If you just add one function to the prototype of all HTMLElements, there's no way to know which element called it. What we need is a way to check to see if specificElement.scrollIntoView.hasBeenCalled().

The first thing we need to understand is what are the elements of our solution? The first thing is we need to figure out how to take fine-grained control of the creation of HTMLElements, and we need to do it before we create those elements. The general outline is that we have to edit the incomplete implementation of HTMLElement in jsdom to allow us to create one jest mock per element instance.

Let's start with how we're going to get Jest to load the code that augments jsdom's implementation, because there's no point in writing the code if we can't get it pulled in. Jest has a configuration option, setupFilesAfterEnv, that's designed to allow you to specify one or more files to load after the environment has booted but before any tests have run. If that option already points to a file, you can just add your code there. Otherwise, you'll need to create that file and point to it.

The next thing we need to figure out is how to add to the implementation created by jsdom in a way that lets us specify one mock scrollToTop per instance. For that, we're going to turn to good old Obect.defineProperties(), which we'll use on window.HTMLElement.prototype. The trick, though, is to figure out how to create and store the instance of the mock on each element instance.

 For that, I use a pattern I think of as "get and/or create." Object.defineProperties() lets you specify a getter function. And the nice thing about functions is that they can do things other than just getting. While normally I would say that you don't want to write a getter that has a side effect, in this case we need to leverage that, because when we define our getter we know that it will run in the scope of the instance we're trying to add to. The other thing we need is a place to store that instance so that, again, it's unique to each instance. Which is why we're using defineProperties and not defineProperty.

So the finished code looks like this:

Object.defineProperties(window.HTMLElement.prototype,
    {
        _scrollIntoView: {
            value: undefined,
            writable: true,
            configurable: true
        },
        scrollIntoView: {
            configurable: true,
            enumerable: true,
            get() {
                if (!this._scrollIntoView) this.scrollIntoView = jest.fn().mockName('scrollIntoView');
                return this._scrollIntoView;
            }
        }
    }

I hope you've found this post helpful. If so, please leave me a comment. Happy coding!

0 comments: