Mounting components and asserting on the DOM

Daniel Irvine šŸ³ļøā€šŸŒˆ - Jan 12 '20 - - Dev Community

In the last part we set up our testing environment ready for tests. Now weā€™ll set up JSDOM, mount our component under test, and then check that it rendered the right elements into the DOM tree.

As before, all this code is in the repo.

GitHub logo dirv / svelte-testing-demo

A demo repository for Svelte testing techniques

Hereā€™s a simple component, in src/StaticComponent.js.

<script>
  export let who = "human";
</script>

<main>
  <button>Click me, {who}!</button>
<main>
Enter fullscreen mode Exit fullscreen mode

To test this, we need to mount the component into a DOM container and then look at the rendered query output.

Before writing the test weā€™ll need to build up some helper functions to help us out. But hereā€™s a sneak-peak of the first test weā€™ll end up writing.

it("renders a button", () => {
  mount(StaticComponent);
  expect(container).toMatchSelector("button");
});
Enter fullscreen mode Exit fullscreen mode

Notice the custom matcher toMatchSelector. In this post Iā€™ll write a Jasmine matcher for that, which translates fairly simply to Jest and to other matcher systems too.

Setting up JSDOM

Before mounting a component, weā€™ll need to ensure that the DOM is available.

The file spec/support/svelte.js contains the following helper function.

import { JSDOM } from "jsdom";

const setupGlobalJsdom = (url = "https://localhost") => {
  const dom = new JSDOM("", { url, pretendToBeVisual: true });
  global.document = dom.window.document;
  global.window = { ...global.window, ...dom.window };
  global.navigator = dom.window.navigator;
};
Enter fullscreen mode Exit fullscreen mode

This creates a new JSDOM instance and assigns it to the document, window and navigator globals. Thereā€™s a few interesting points:

  • The URL can be passed in which helps if youā€™re testing any behavior that relies on the current page location
  • The window object merges in the existing window object if it exists, which allows you to stub out functions like window.fetch before you set up the DOM.
  • The option pretendToBeVisual means that the requestAnimationFrame API is enabled for testing, which will be useful if your app calls that function.

More information about JSDOM can be found in the JSDOM GitHub README. One point is that they recommend against setting the document as a global in the way we are here. But unfortunately if you donā€™t do that, Svelte wonā€™t work as it expects to find a global document instance.

Creating a container for each test

The spec/support/svelte.js file also contains this function.

const createContainer = () => {
  global.container = document.createElement("div");
  document.body.appendChild(container);
};
Enter fullscreen mode Exit fullscreen mode

Thatā€™s straightforward enough; and what you might expect. Itā€™s not strictly necessary to append the container to the document.body node, by the way. There are probably some use cases where it is necessary, but for many tests it wonā€™t be.

Now thereā€™s one more thing to do: define a function to call both setupGlobalJsdom and createContainer. However, it also does one more thing. Take a look.

let mountedComponents;

export const setDomDocument = url => {
  setupGlobalJsdom(url);
  createContainer();
  mountedComponents = [];
};
Enter fullscreen mode Exit fullscreen mode

This function also initializes a mountedComponents array. This array will be used to unmount all components after a test is complete. Weā€™ll come back to that when we define the unmount function later.

Notice this function is also defined as an export so weā€™re ready to use it in our tests.

So how do we use this in our tests? Like this, in the file spec/StaticComponent.spec.js. By the way, this isnā€™t the end resultā€”weā€™re going to improve on this later in this post.

import { setDomDocument } from "./support/svelte.js";

describe(StaticComponent.name, () => {
  beforeEach(() => setDomDocument());
});
Enter fullscreen mode Exit fullscreen mode

In case you are thinking that beforeAll will do, Iā€™d recommend against that. JSDOM is quick to setup and itā€™s always better to start each test with a clean slate.

Mounting components

Time to define mount, which exists in the same file, spec/support/svelte.js.

It comes in two parts: a function setBindingCallbacks, and the mount function itself.

import { bind, binding_callbacks } from "svelte/internal";

const setBindingCallbacks = (bindings, component) =>
  Object.keys(bindings).forEach(binding => {
    binding_callbacks.push(() => {
      bind(mounted, binding, value => {
        bindings[binding] = value
      });
    });
  });

export const mount = (component, props = {}, { bindings = {} } = {}) => {
  const mounted = new component({
    target: global.container,
    props
  });
  setBindingCallbacks(bindings, mounted);
  mountedComponents = [ mounted, ...mountedComponents ];
  return mounted;
};
Enter fullscreen mode Exit fullscreen mode

The setBindingCallbacks is necessary for testing Svelte component bindings. The code here is plumbing that you donā€™t need to worry about.

However, since it relies on svelte/internal it is subject to change and this API could break in future. Iā€™ll come back to this in a future part; it turns out that testing bindings (both one-way and two-way) is not straightforward.

Interestingly enough, the component is always mounted at the container root. The test gets no choice about that. Each test you write will only have the option of mounting one component under test at any one time. This is standard for unit testing.

Unmounting

Now letā€™s look at how we can unmount components. We should do this after each test using an afterEach call.

When I was unit testing React, unmounting components wasnā€™t always necessary. But with Svelte, I find it is pretty essential. Tests will often break subsequent tests if this isnā€™t done. I donā€™t know enough of the Svelte internals to know why that is.

By the way, if you were writing onDestroy handlers youā€™d be using this function (which also appears in spec/support/svelte.js) as part of the act phase of your test, not the arrange phase.

export const unmountAll = () => {
  mountedComponents.forEach(component => {
    component.$destroy()
  });
  mountedComponents = [];
};
Enter fullscreen mode Exit fullscreen mode

Putting it together: our first test

Hereā€™s what a first test looks like.

import { mount, setDomDocument, unmountAll } from "./support/svelte.js";
import StaticComponent from "../src/StaticComponent.svelte";

describe(StaticComponent.name, () => {
  beforeEach(() => setDomDocument());
  afterEach(unmountAll);

  it("renders a button", () => {
    mount(StaticComponent);
    expect(container.querySelector("button")).not.toBe(null);
  });
});
Enter fullscreen mode Exit fullscreen mode

There are two things I want to improve on this:

  1. Creating a more descriptive matcher, toMatchSelector, which beats a not.toBe(null) any day.
  2. Pull the beforeEach and afterEach into their own function as a helper.

Quick side note: Writing beforeEach(setDomDocument) wonā€™t work as Jasmine actually passes an argument to beforeEach blocks, which our helper would pick up as the url parameter.

Defining a toMatchSelector matcher

The reason this is important is so that if it fails, your test exception tells you as much useful information as possible.

Letā€™s take the example above to see what I mean. Taking this expectation:

expect(container.querySelector("button")).not.toBe(null);
Enter fullscreen mode Exit fullscreen mode

When this expectation fails, the output is this:

Expected null not to be null. Tip: To check for deep equality, use .toEqual() instead of .toBe().
Enter fullscreen mode Exit fullscreen mode

This is totally useless. Expected null not to be null. Great!

How about this instead?

Expected container to match CSS selector "button" but it did not.
Enter fullscreen mode Exit fullscreen mode

Much more useful. So letā€™s write a custom matcher to do that.

This is a Jasmine custom matcher but a Jest custom matcher looks very similar, except it has a slightly nicer API and itā€™s easy to add pretty colors to the output.

This matcher also lives in spec/support/svelte.js.

const toMatchSelector = (util, customEqualityTesters) => ({
  compare: (container, selector) => {
    if (container.querySelector(selector) === null) {
      return {
        pass: false,
        message: `Expected container to match CSS selector "${selector}" but it did not.`
      }
    } else {
      return {
        pass: true,
        message: `Expected container not to match CSS selector "${selector}" but it did.`
      }
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Youā€™ll notice I havenā€™t marked this as export, but we need some way for our test to register this matcher with Jasmine. Iā€™m going to skip ahead and tie this in to the second of our refactorings above, of pulling the beforeEach and afterEach into their own helper.

Defining asSvelteComponent

This is a real beauty:

export const asSvelteComponent = () => {
  beforeEach(() => setDomDocument());
  beforeAll(() => {
    jasmine.addMatchers({ toMatchSelector });
  });
  afterEach(unmountAll);
};
Enter fullscreen mode Exit fullscreen mode

Isnā€™t that lovely?

Now letā€™s rewrite our test to use this new set up:

import { mount, asSvelteComponent } from "./support/svelte.js";
import StaticComponent from "../src/StaticComponent.svelte";

describe(StaticComponent.name, () => {
  asSvelteComponent();

  it("renders a button", () => {
    mount(StaticComponent);
    expect(container).toMatchSelector("button");
  });
});
Enter fullscreen mode Exit fullscreen mode

Elegant, concise, and it works. Yum.

Testing props and adding the element helper

To finish off this part, hereā€™s two more tests, together with an element helper function.

const element = selector => container.querySelector(selector);

it("renders a default name of human if no 'who' prop passed", () => {
  mount(StaticComponent);
  expect(element("button").textContent).toEqual("Click me, human!");
});

it("renders the passed 'who' prop in the button caption", () => {
  mount(StaticComponent, { who: "Daniel" });
  expect(element("button").textContent).toEqual("Click me, Daniel!");
});
Enter fullscreen mode Exit fullscreen mode

I like the element helper because it allows our expectations to read like ā€œproperā€ English.

You can also define elements like this:

const elements = selector =>
  Array.from(container.querySelectorAll(selector));
Enter fullscreen mode Exit fullscreen mode

Thatā€™s it for this part. Weā€™ve now built up a good selection of helpers that allows us to write clear, concise tests. In the next section weā€™ll look at testing onMount callbacks.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player