Update JSPM (SystemJS) + JSDOM + Vue testing integration

More changes & improvements to my JSPM (SYSTEMJS) + JSDOM + Vue testing strategy

vados

5 minute read

tl;dr - JSDOM has updated so my testing code that uses it has updated as well, ignore the editorial and look at the code.

A while ago, I wrote a bit about some code I often use for integration testing on the front end. Since “integration” can mean a lot of things, to clarify, I am referring to making sure components render the right HTML under certain states (not loading a full page, not testing the render function). This code centered a lot around jsdom, an excellent close-enough-to-browser-land tool for running JS. After a while, some changes to jsdom prompted a revisit of the code, as it stopped working. This post is yet another revisit in the same tradition, responding to updates in JSDOM that broke my tests.

The updates to JSDOM that broke my tests generally were:

  • Changes to some APIs, particularly around renderModuleInJSDOM which was heavily simplified (and I appreciated)
  • runScripts: "dangerously" option being added (which I also appreciate, is good for security)
  • Fix (?) of the browser/node context separation – test code that was executing in the browser context no longer had access to should (which I required in node context), which is arguably how it should be.

The code

Without any future ado here’s the code:

/**
 * Render a component with data provided
 *
 * @param {Object} Vue - The vue instance to use to render the component
 * @param {Object} Component - The component to render
 * @param {Object} props - The data to feed in to the component at init
 * @returns A promise that resolves to the `window` element of a DOM
 */
exports.renderComponentWithData = function(Vue, Component, props) {
  // Create component and mount
  var ModifiedVue = Vue.extend(Component);
  var vm = new ModifiedVue(props || {});

  return new Promise(function(resolve, reject) {
    // Render the component
    renderer.renderToString(vm, function(err, html) {
      if (err) reject(err);
      resolve(new jsdom.JSDOM(html).window);
    });
  });
};

/**
 * Render a module (usually a component) in JSDOM, by:
 * - Creating a JSPM builder
 * - Building a static bundle of the module
 * - Loading the bundle with JSDOM
 *
 * @param {string} path - Path to the module code
 * @param {string} name - The name to use for the name of the exported module
 * @param {string} [builder] - JSPM builder to re-use
 * @returns A promise that resolves with the window of the generated jsdom environment
 */
function renderModuleInJSDOM(path, name, builder) {
  // Make a static build of the module
  return (builder || new JSPM.Builder())
    .buildStatic(path, { minify: true, globalName: "window." + name })
    .then(output => new jsdom.JSDOM(`<script>${output.source}</script>`, {runScripts: "dangerously"}).window);
}
exports.renderModuleInJSDOM = renderModuleInJSDOM;

/**
 * Instantiate a component into an off-page element from a loaded global module in JSDOM
 * The component module is expected to have `vue` and `component` properties.
 *
 * @param {string} globalName - The name of the loaded component module.
 * @param {Object} data - Data to pass to the component on initialization
 * @param {Object} data.propsData - Initial props for the component
 * @returns A function that operates on a window, and resolves to original window, the vm, and element or the relevant component
 */
function instantiateComponentFromLoadedGlobal(globalName, data) {
  return function(window) {
    if (typeof window[globalName] === "undefined" || window[globalName] == null) {
      throw new Error("Incorrect/Invalid global name, failed to find window[" + globalName + "]");
    }

    // Grab the loaded element, component, and vue fn
    var vueFn  = window[globalName].default.vue;
    var comp  = vueFn.extend(window[globalName].default.component);

    // Create and mount component into off-page element
    var vm = (new comp(data)).$mount();
    var elem = vm.$el;

    // NOTE - the element is NOT on the page at this point, it"s off-page
    // The structure below is referred to as an ElemInfo object frequently.
    return {vm, elem, vueFn, comp, window};
  };
}

exports.instantiateComponentFromLoadedGlobal = instantiateComponentFromLoadedGlobal;

function isValidElemInfoObj(obj) {
  return obj.vm && obj.elem && obj.window && obj.comp;
}

/**
 * Add component"s element (expeted to be off-page) to the body of the HTML document. Usually called after `instantiateComponentFromLoadedGlobal`
 *
 * @param {ElemInfo} info - Information about element/env see `instantiateComponentFromLoadedGlobal`
 * @returns A an object (elemInfo) that contains the window, the vm, and element or the relevant component. see `instantiateComponentFromLoadedGlobal`
 */
function addComponentElementToBody(info) {
  if (!isValidElemInfoObj(info)) throw new Error("Invalid ElemInfo object:", info);

  // Add elem to document body
  info.window
    .document
    .documentElement
    .getElementsByTagName("body")[0]
    .appendChild(info.elem);

  // NOTE - the element is NOT on the page at this point, it"s off-page
  return info;
}

exports.addComponentElementToBody = addComponentElementToBody;

/**
 * Simulate a click in JSDOM
 *
 * @param {EventTarget} tgt - The element that should be the target for the click
 * @param {Window} window - The JSDOM-provided window of the page
 * @param {object} options - Click event options (by default an cancelable, event that bubbles, with the given element as the view)
 * @param {Function} tgt.dispatchEvent - The element should be able to dispatch events.
 */
function jsdomClick(tgt, window, options) {
  tgt.dispatchEvent(
    new window.MouseEvent("click", options || { bubbles: true, cancelable: true, view: tgt })
  );
}
exports.jsdomClick = jsdomClick;

A few notes to go with this:

  • There are some utility functions in there that you may or may not need.
  • At some point I had problems with JSDOM clicks registering properly with Vue’s click handler so I left that function in just in case others have the same issue (maybe it’s changed/fixed now)

An example for use

Here’s an example of one of these bad boys in action, using tape (which is what I use for all my testing now).

test("login-page component should render properly with no options", t => {
  TestUtil.renderModuleInJSDOM(COMPONENT_JS_PATH, "Component")
    .then(TestUtil.instantiateComponentFromLoadedGlobal("Component"))
    .then(elemInfo => {
      t.assert( elemInfo.vm , "VM should be load properly in JSDOM");
      t.end();
    })
    .catch(TestUtil.failAndEnd(t));
});

There’s not a crazy amount there being tested, but it’s enough to demonstrate how I have used the functions defined above. Of course, you can go lots of places from here, using more of the items that elemInfo provides (vm is the component, elem being the dom element attached to the component, window, etc).

Did you find this read beneficial? Send me questions/comments/clarifciations.
Want my expertise on your team/project? Send me interesting opportunities!