Daniel Irvine š³ļøāš
Posted on January 12, 2020
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.
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>
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");
});
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;
};
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 existingwindow
object if it exists, which allows you to stub out functions likewindow.fetch
before you set up the DOM. - The option
pretendToBeVisual
means that therequestAnimationFrame
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);
};
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 = [];
};
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());
});
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;
};
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 = [];
};
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);
});
});
There are two things I want to improve on this:
- Creating a more descriptive matcher,
toMatchSelector
, which beats anot.toBe(null)
any day. - Pull the
beforeEach
andafterEach
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);
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().
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.
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.`
}
}
}
});
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);
};
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");
});
});
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!");
});
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));
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.
Posted on January 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.