DEV Community

loading...

Mounting components and asserting on the DOM

Daniel Irvine 🏳️‍🌈
I’m the author of Mastering React Test-Driven Development, available now from Packt. I run the Queer Code London meetup.
・6 min read

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.

Discussion (0)