Testing a Svelte app with Jest

robole

Rob OLeary

Posted on November 18, 2021

Testing a Svelte app with Jest

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:

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
Enter fullscreen mode Exit fullscreen mode

With Yarn:

yarn create vite example-svelte-app --template svelte
cd example-svelte-app
yarn install
Enter fullscreen mode Exit fullscreen mode

With PNPM:

pnpm create vite example-svelte-app --template svelte
cd example-svelte-app
pnpm install
Enter fullscreen mode Exit fullscreen mode

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.

simple cover image of vs code logo

Configuration

We need the following libraries to get set-up for testing:

  1. Jest is the test runner that we will use. It also has some assertion and mocking functionality.
  2. @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.
  3. 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, and jest-transform-stub for importing non-JavaScript assets (images, CSS, etc).
  4. @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, and getByRole.
  5. @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 the Ctrl key is being pressed. You may not need this, but it is worth knowing about it.
  6. 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.
  7. @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(), and toBeVisible(). This is optional too.
  8. 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:

  1. 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
    
  2. 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"]
    }
    
  3. 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"]
    }
    
  4. Add the scripts to run the tests in your package.json

   "test": "jest src",
   "test:watch": "npm run test -- --watch"
Enter fullscreen mode Exit fullscreen mode
  1. 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();
})
Enter fullscreen mode Exit fullscreen mode

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 and getByLabelText.

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.
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Whenever the button is pressed, it increments a count variable that is displayed in the button label.

counter component

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
Enter fullscreen mode Exit fullscreen mode

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.

counter component

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.

todo app screenshot

Requirements

The app should do the following:

  1. List todos. When there are no items, the message "Congratulations, all done!" should be shown.
  2. 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.
  3. 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

todo components figure

  1. 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 to TodoList. We hardcode 3 todos in our app , as per screenshot above.
  2. The AddTodo component contains the form with an text input and button to add new todos to our list.
  3. The TodoList component is an unordered list of the todos. It has a todos prop that is an array of todo objects. Each list item contains a Todo component.
  4. The Todo component shows the text of the todo and has a checkbox for marking the item as done. It has a todo 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..
});
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
});   
Enter fullscreen mode Exit fullscreen mode

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);
  });

});
Enter fullscreen mode Exit fullscreen mode

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%.

counter component

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!

💖 💪 🙅 🚩
robole
Rob OLeary

Posted on November 18, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Testing a Svelte app with Jest
webdev Testing a Svelte app with Jest

November 18, 2021