Testing a Svelte app with Jest
Rob OLeary
Posted on November 18, 2021
I have seen very little written about testing Svelte components. I have yet to see a tutorial build and test a Svelte app! This is disconcerting. Maybe, testing is not considered a sexy topic, or for hobby projects people like to walk on the wild side. I don't know. In any case, it is not a good idea for any project to skip it! "Practice as you intend to play" is my philosophy!
Svelte hasn't anointed a set of testing tools or does not advocate for a particular testing strategy. It gives some basic advice. More established frameworks have recommendations and integrations specific to their CLI tooling - React recommends using Jest and React Testing Library, and Vue recommends using Mocha or Jest with Vue Testing Library. In theory, you can use whatever JavaScript testing library you want for testing, because in the end you will be testing JavaScript code, regardless of whether it is transpiled or not. However, it can prove to be tricky to integrate different tools into a frontend toolchain for your "dev stack".
Svelte has relied on Rollup for as the central point for it's dev tooling so far, but recently Vite has been adopted by SvelteKit. Vite is among the next generation frontend tooling brigade. It provides a much faster dev environment, hence the name, vite means fast in French. It uses native ECMAScript Modules (ESM) to provide on-demand file serving, which means updates are instantly reflected without reloading the page or blowing away application state.
While the new direction for Svelte appears to be set, the current state of affairs is that most testing frameworks are still "last generation"! They mostly use commonJS modules and need to adjust to this new paradigm. You can see the issue "feature: first class Jest integration" in the Vite GithHub repo to see some of the issues you can run into. In the meantime, you need to transpile your code and do some extra hacks and configuration to get everything to play nice. This is never fun!
In this tutorial, I will go through using Svelte with Vite, and show you how to test a complete app with Jest. I will be using JavaScript, but I will mention the extra steps you need to take if you want to use TypeScript instead. I will be testing a simple Todo app to clearly demonstrate what testing looks like without too much complexity or clutter.
Let's get to it!
TLDR
Here are the GithHub repositories for the code I cover in the article:
- Starter template - https://github.com/robole/svelte-vite-jest-template.
- Todo app - https://github.com/robole/svelte-todo-with-tests.
Getting started from a template
Let's create a Svelte project based on the Vite "svelte" template, and call it example-svelte-app. For TypeScript, use the "svelte-ts" template instead.
With NPM 7+, you must supply an extra set of double hypens :
npm init vite@latest example-svelte-app -- --template svelte
cd example-svelte-app
npm install
With Yarn:
yarn create vite example-svelte-app --template svelte
cd example-svelte-app
yarn install
With PNPM:
pnpm create vite example-svelte-app --template svelte
cd example-svelte-app
pnpm install
Now, we have a default project. It says "HELLO WORLD!" and has a Counter
component. We can run the project with npm run dev
and visit it at localhost:3000.
Configuration
We need the following libraries to get set-up for testing:
- Jest is the test runner that we will use. It also has some assertion and mocking functionality.
- @babel/core, babel-jest and @babel/preset-env are required for the transpilation Jest requires. Jest uses commonJS by default, and we are using ECMAScript Modules (ESM) in our code, so we need to get them in the same form. The latest version of Jest is v27.2 and has experimental support for ESM. I did not want to go down the experimental road! Hopefully, this will mature quickly and remove the need for Babel in the toolchain if you are using JavaScript.
-
svelte-jester and jest-transform-stub. Jest does not understand how to parse non-JavaScript files. We need to use
svelte-jester
to transform Svelte files, andjest-transform-stub
for importing non-JavaScript assets (images, CSS, etc). -
@testing-library/svelte (known as Svelte Testing Library) provides DOM query functions on top of Svelte in a way that encourages better testing practices. Some of the most commonly used functions are
render
,getByText
,getByLabelText
, andgetByRole
. -
@testing-library/user-event is a companion library to Svelte Testing Library that provides more advanced simulation of browser interactions than the built-in
fireEvent
function. An example of this is if you need to trigger an event for a mouse click while theCtrl
key is being pressed. You may not need this, but it is worth knowing about it. - If you use global environment variables or a
.env
file in your code, you need to install babel-plugin-transform-vite-meta-env to transform these variables for the commonJS module. This is not a permanent solution (famous last words, I know). You can read this issue for more details on the hopes for better integration where this would not be necessary. -
@testing-library/jest-dom provides a set of custom jest matchers that you can use to extend jest. These can be used to make your tests more declarative. It has functions such as
toBeDisabled()
,toBeInTheDocument()
, andtoBeVisible()
. This is optional too. - If you are using Typescript, you need to install svelte-preprocess and ts-jest. also.
We need to install these libraries and do some configuration before we can get to our tests:
-
I will install the aforementioned libraries with NPM without the TypeScript dependencies:
npm install -D jest babel-jest @babel/preset-env svelte-jester jest-transform-stub @testing-library/svelte @testing-library/user-event babel-plugin-transform-vite-meta-env @testing-library/jest-dom
-
We need to configure Jest to transform our files. We must explicitly set our test environment to jsdom, which we are using through Jest. Since v27 Jest's default test environment is node. I will put the configuration in a specific Jest configuration file called jest.config.json in the project root folder. If you create a configuration file called jest.config.js, Vite will complain as it expects only ESM JavaScript by default. Vite will recommend that you rename it to a ".cjs" file if you want to do it that way. You can look at the different ways to configure Jest if you are unsure about the file conventions. If you're using TypeScript, you need to configure svelte-preprocess and ts-jest also, see the svelte-jester docs for how to do that.
{ "transform": { "^.+\\.js$": "babel-jest", "^.+\\.svelte$": "svelte-jester", ".+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$": "jest-transform-stub" }, "moduleFileExtensions": ["svelte", "js"], "testEnvironment": "jsdom", "setupFilesAfterEnv": ["@testing-library/jest-dom/extend-expect"] }
-
We configure Babel to use the current version of node. Include the babel-plugin-transform-vite-meta-env plugin if you are using environment variables. I will put the configuration in a .babelrc file in the project root folder. If you are using TypeScript, you need to add a TypeScript preset also, see the Jest docs for the details.
{ "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]], "plugins": ["babel-plugin-transform-vite-meta-env"] }
Add the scripts to run the tests in your
package.json
"test": "jest src",
"test:watch": "npm run test -- --watch"
-
Let's see if our set-up is correct by running
npm run test
. Since we don't have any tests yet, you should see following message in console.
➜ npm run test> example-svelte-app@0.0.0 test > jest src No tests found, exiting with code 1
Whew, that's a lot! I wasn't lying when I said that it can prove to be tricky to integrate different tools into a frontend toolchain! 😅
If you are using SvelteKit, this should work also. I have not delved into SvelteKit yet, so I don't know if something slightly different is required. If there is, let me know!
Your first unit test
Now, lets create a test module for our App.svelte component called App.spec.js in the same folder. By default Jest looks for filenames that end with either ".spec.js" or ".test.js".
import { render, screen } from '@testing-library/svelte';
import App from './App.svelte';
test("says 'hello world!'", () => {
render(App);
const node = screen.queryByText("Hello world!");
expect(node).not.toBeNull();
})
We need to import the component, and the functions we use from the Svelte Testing Library.
We pass our component to the render
function to setup our component. Svelte Testing Library creates a screen
object for us that is bound to document.body
of the virtual document. We can use this to run some of the builtin DOM query functions against.
Here, we use the queryByText
function to look for an element with that text content. It will return a node object if it finds an element with that text. It will return null
if no elements match.
For details on the query functions , see the DOM Testing Library’s “Queries” documentation. Some of the most commonly used query functions are
getByText
andgetByLabelText
.
Next, we use some of Jest's expect matchers to check that the node is not null.
Alternatively, you can use expect(node).toBeInDocument()
from @testing-library/jest-dom. This is a bit easier to read I guess(?), so we will use this from now on.
When we run the test, we get the folllowing output:
➜ npm run test
> example-svelte-app@0.0.0 test> jest src
PASS src/App.spec.js
✓ says 'hello world!' (33 ms)
Test Suites: 1 passed, 1 totalTests:
1 passed, 1 totalSnapshots: 0 total
Time: 1.711 s
Ran all test suites matching /src/i.
You don't need to destroy the component after each test, this is done automagically for you!
Typically, you would explicitly create a test suite for each component with the function describe(name, fn)
. We wrap our tests in a function and pass it as the second argument. It usually look like this:
describe("App", () => {
test("says 'hello world!'", () => {
render(App);
const node = screen.queryByText("Hello world!");
expect(node).toBeInTheDocument();
});
});
You will see that some people use the it()
function instead of test()
also. It's the same thing, just a different style. The it
function is influenced by rspec.
Testing events
Lets test our Counter
component by creating a Counter.spec.js file in the same folder (lib).
<script>
let count = 0
const increment = () => {
count += 1
}
</script>
<button on:click={increment}>
Clicks: {count}
</button>
Whenever the button is pressed, it increments a count
variable that is displayed in the button label.
We will create a similar test to our first test for the App
. We just want to check that the button is rendered.
import { render, screen, fireEvent } from "@testing-library/svelte";
import Counter from "./Counter.svelte";
describe("Counter", () => {
test("it has a button with the text 'Clicks: 0'", async () => {
render(Counter);
const button = screen.getByText("Clicks: 0");
expect(button).toBeInTheDocument();
});
});
Now, we want to check the action will increment the count. This is where we reach for the fireEvent
function. There is a convenient form of the function fireEvent[eventName](node: HTMLElement, eventProperties: Object)
where we can provide the event name as a suffix. So, we can write fireEvent.click(screen.getByText("Clicks: 0")
. Because this is an asynchronous event, we need to use the await
syntax and make our test function async
. The test function looks this:
test("it should increment the count by 1 when it the button is pressed", async () => {
render(Counter);
const button = screen.getByText("Clicks: 0");
await fireEvent.click(button);
expect(screen.getByText("Clicks: 1")).toBeInTheDocument();
});
You can use the user-event library instead, but be aware that all events are treated as async in Svelte testing. For other frameworks, they are probably synchronous. This is unique to the Svelte because the library must wait for the next tick
so that Svelte flushes all pending state changes.
We can check the test coverage of our app now by running npx jest --coverage
.
And we're at 100% coverage. Yay!
Unit tests for a Todo app
While we're at it, let's test a more complete app. This is where we can really see what testing is like. Let's look at a minimal Todo app.
Requirements
The app should do the following:
- List todos. When there are no items, the message "Congratulations, all done!" should be shown.
- Allow a user to mark/unmark todos as done. When a todo is done, it is styled differently. The text color is gray and has a strike-through decoration.
- Allow a user to add new todos, but prohibit the addition of an empty todo.
We will write our tests on these requirements.
Component overview
- The
App
component contains the other components. It has a subheading that shows the status of the todos e.g "1 of 3 remaining ". It passes an array of todos toTodoList
. We hardcode 3 todos in our app , as per screenshot above. - The
AddTodo
component contains the form with an text input and button to add new todos to our list. - The
TodoList
component is an unordered list of the todos. It has atodos
prop that is an array of todo objects. Each list item contains aTodo
component. - The
Todo
component shows the text of the todo and has a checkbox for marking the item as done. It has atodo
prop that is a todo object.
The child components dispatch events up to the App
when there are data changes from user interaction. For example, Todo
dispatches a toggleTodo
event whenever it's checkbox is clicked, this event is forwarded by TodoList
to App
to handle this event.
Tests
I will highlight a couple of the unique aspects of the tests to demonstrate some of the methods for using Jest.
Testing with props and classes (Todo.spec.js
)
This is an example of passing props to components when we are testing. We pass them through an object we provide as the second argument to the render
function.
describe("Todo", () => {
const todoDone = { id: 1, text: "buy milk", done: true };
const todoNotDone = { id: 2, text: "do laundry", done: false };
test("shows the todo text when rendered", () => {
render(Todo, { props: { todo: todoDone } });
expect(screen.getByLabelText("Done")).toBeInTheDocument(); //checkbox
expect(screen.getByText(todoDone.text)).toBeInTheDocument();
});
//etc..
});
In this test case, we want to get the checkbox for the todo. It has a lable of "Done", so we can get it through the function getByLabelText()
. The checkbox has an aria-label
attribute rather than a corresponding label
element, it does not matter which it is. I like to favour using this function as it is a a good reminder to ensure that every input should have a label to keep things accessible for everyone.
Next, we want to test when a Todo item is marked as done.
test("a done class should be added to the text item when a todo is done", () => {
render(Todo, { props: { todo: todoDone } });
expect(screen.getByText(todoDone.text)).toHaveClass("done");
});
When the checkbox is checked, a done
class is added to the span
element that has the todo text. We can use the toHaveClass()
function to check that this class is added correctly for done todos.
Testing text entry (AddTodo.spec.js
)
To simulate a user entering text into the textbox, we use the type
function from the @testing-library/user-event library. In this case, the button is only enabled when text is entered.
import { render, screen } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import AddTodo from "./AddTodo.svelte";
describe("AddTodo", () => {
// other stuff
test("the add button should be enabled when text is entered", async () => {
render(AddTodo);
await userEvent.type(screen.getByLabelText("Todo"), "abc");
expect(screen.getByRole("button")).toBeEnabled();
});
});
Testing data mutation (App.spec.js
)
You may have expected the adding of a new todo to be tested in AddTo.spec.js
. However, since the AddTodo
component doesn't result in a DOM change, rather it fires an AddNew
event, there is no way for us to test it through DOM query methods. The action is delegated to the App
component, so this is where we will test it.
import { render, screen, fireEvent } from "@testing-library/svelte";
import App from "./App.svelte";
describe("App", () => {
const PREDEFINED_TODOS = 3;
// other stuff
test("should add a todo", async () => {
render(App);
const input = screen.getByLabelText("Todo");
const value = "Buy milk";
await fireEvent.input(input, { target: { value } });
await fireEvent.click(screen.getByText("Add"));
const todoListItems = screen.getAllByRole("listitem");
expect(screen.getByText(value)).toBeInTheDocument();
expect(todoListItems.length).toEqual(PREDEFINED_TODOS + 1);
});
});
In this test case, we must simulate inserting some text to the textbox, and then hitting the "Add" button. I use fireEvent.input
to pass the text to the textbox to its value
property. This function is similar to userEvent.type
that I used in the previous example. I use it here to show you both ways, use whichever you prefer. Don't forget that these actions are asynchronous, so always use await
.
For our test assertion, we want to check that the text for our new todo is now added to the document. This should be familiar by now - expect(screen.getByText(value)).toBeInTheDocument();
.
We can be doubly sure of the success of our action by checking the number of todos in the page. Because the todo items are added to the only list in the page, we can check the number of todos by getting elements that match the accessibility role of listitem
through screen.getAllByRole("listitem")
. We can then get the length of the returned array to check how many items there are.
In more complicated apps, you may need not be able to find the elements you are after by searching by text, label or role. If there is no way around it, you can reach for querySelector()
on the document body like you would in vanilla JavaScript on a regular webpage. Just try to avoid using this 'escape hatch' if possible.
Some people may choose to defer some of the testing of the App
component to end-to-end testing. It depends on who you are working with, and how the project is organized to decide who tests what, and where.
And that's the bits that I think stand out the most, you can read through the tests yourself to get a more complete grasp.
The test coverage is 98%.
One important thing that I did not cover in my app is Test Doubles. Even though it is quite a small app, I wrote what are called social tests. The alternate approach is solitary tests. For solitary tests, you need to mock components, you are trying to isolate a component and only the test the functionality of that "unit".
In both approaches, you may need to mock some functions that rely on third-party libraries or native browser APIs. One common example is mocking calls to backend services through fetch
or axios
. I didn't use a backend service in my app, so I did not need to mock anything. This is something that I may pick up in another article.
Conclusion
It is messy to get Jest set-up with Svelte and Vite. The template I have provided here will allow you start testing your Svelte components out of the gates. While you can get quite far without issues, using ESM in your frontend code and dev tools, but using a testing library that uses CommonJS, will inevitably create more work for you. I guess we will have to wait and see if Jest will make this simpler with its ESM support, and whether Vite will offer first class Jest integration some time soon.
I would like to find an alternative unit testing library that requires less configuration and integrates with Vite and Svelte in a more seamless way. I wonder if using a testing framework such as Jest that uses jsdom, a virtual DOM implementation, under the hood is avoidable. If Svelte has ditched the virtual DOM, could the testing framework do the same? Getting closer to actual browser experience will make testing a bit more realistic too. This feels like a neglected aspect of the frontend dev stack evolution to me.
Regardless of the details, I encourage you to test your Svelte apps and make testing a core part of your development process. I hope I have shown that it is easier than you may think! The confidence that you will get from testing is invaluable to make more reliable and resilient apps. Don't treat it as an optional task for your own sake!
Posted on November 18, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.