Skip to content

Component Testing in Svelte

🔗Component Testing in Svelte

Testing helps us trust our application, and it's a safety net for future changes. In this tutorial, we will set up our Svelte project to run tests for our components.

🔗Starting a new project

Let's start by creating a new project:

pnpm dlx create-vite
// Project name: › testing-svelte
// Select a framework: › svelte
// Select a variant: › svelte-ts

cd testing-svelte
pnpm install

There are other ways of creating a Svelte project, but I prefer using Vite. One of the reasons that I prefer using Vite is that SvelteKit will use it as well. I'm also a big fan of pnpm, but you can use your preferred package manager. Make sure you follow Vite's docs on starting a new project using npm or yarn.

🔗Installing required dependencies

  • Jest: I'll be using this framework for testing. It's the one that I know best, and feel more comfortable with. Because I'm using TypeScript, I need to install its type definitions too.
  • ts-jest: A transformer for handling TypeScript files.
  • svelte-jester: precompiles Svelte components before tests.
  • Testing Library: Doesn't matter what framework I'm using, I will look for an implementation of this popular library.
pnpm install --save-dev jest @types/jest @testing-library/svelte svelte-jester ts-jest

🔗Configuring tests

Now that our dependencies are installed, we need to configure jest to prepare the tests and run them.

A few steps are required:

  • Convert *.ts files
  • Complile *.svelte files
  • Run the tests

Create a configuration file at the root of the project:

// jest.config.js
export default {
  transform: {
    '^.+\\.ts$': 'ts-jest',
    '^.+\\.svelte$': [
      'svelte-jester',
      {
        preprocess: true,
      },
    ],
  },
  moduleFileExtensions: ['js', 'ts', 'svelte'],
};

Jest will now use ts-jest for compiling *.ts files, and svelte-jester for *.svelte files.

🔗Creating a new test

Let's test the Counter component created when we started the project, but first, I'll check what our component does.

<script lang="ts">
  let count: number = 0;
  const increment = () => {
    count += 1;
  };
</script>

<button on:click={increment}>
  Clicks: {count}
</button>

<style>
  button {
    font-family: inherit;
    font-size: inherit;
    padding: 1em 2em;
    color: #ff3e00;
    background-color: rgba(255, 62, 0, 0.1);
    border-radius: 2em;
    border: 2px solid rgba(255, 62, 0, 0);
    outline: none;
    width: 200px;
    font-variant-numeric: tabular-nums;
    cursor: pointer;
  }

  button:focus {
    border: 2px solid #ff3e00;
  }

  button:active {
    background-color: rgba(255, 62, 0, 0.2);
  }
</style>

This is a very small component where a button when clicked, updates a count, and that count is reflected in the button text. So, that's exactly what we'll be testing.

I'll create a new file ./lib/__tests__/Counter.spec.ts

/**
 * @jest-environment jsdom
 */

import { render, fireEvent } from '@testing-library/svelte';
import Counter from '../Counter.svelte';

describe('Counter', () => {
  it('it changes count when button is clicked', async () => {
    const { getByText } = render(Counter);
    const button = getByText(/Clicks:/);
    expect(button.innerHTML).toBe('Clicks: 0');
    await fireEvent.click(button);
    expect(button.innerHTML).toBe('Clicks: 1');
  });
});

We are using render and fireEvent from testing-library. Be mindful that fireEvent returns a Promise and we need to await for it to be fulfilled. I'm using the getByText query, to get the button being clicked. The comment at the top, informs jest that we need to use jsdom as the environment. This will make things like document available, otherwise, render will not be able to mount the component. This can be set up globally in the configuration file.

What if we wanted, to test the increment method in our component? If it's not an exported function, I'd suggest testing it through the rendered component itself. Otherwise, the best option is to extract that function to another file, and import it into the component.

Let's see how that works.

// lib/increment.ts
export function increment (val: number) {
    val += 1;
    return val
  };
<!-- lib/Counter.svelte -->
<script lang="ts">
  import { increment } from './increment';
  let count: number = 0;
</script>

<button on:click={() => (count = increment(count))}>
  Clicks: {count}
</button>
<!-- ... -->

Our previous tests will still work, and we can add a test for our function.

// lib/__tests__/increment.spec.ts

import { increment } from '../increment';

describe('increment', () => {
  it('it returns value+1 to given value when called', async () => {
    expect(increment(0)).toBe(1);
    expect(increment(-1)).toBe(0);
    expect(increment(1.2)).toBe(2.2);
  });
});

In this test, there's no need to use jsdom as the test environment. We are just testing the function.

If our method was exported, we can then test it by accessing it directly.

<!-- lib/Counter.svelte -->
<script lang="ts">
  let count: number = 0;
  export const increment = () => {
    count += 1;
  };
</script>

<button on:click={increment}>
  Clicks: {count}
</button>
<!-- ... -->
// lib/__tests__/Counter.spec.ts

describe('Counter Component', () => {
 // ... other tests

  describe('increment', () => {
    it('it exports a method', async () => {
      const { component } = render(Counter);
      expect(component.increment).toBeDefined();
    });

    it('it exports a method', async () => {
      const { getByText, component } = render(Counter);
      const button = getByText(/Clicks:/);
      expect(button.innerHTML).toBe('Clicks: 0');
      await component.increment()
      expect(button.innerHTML).toBe('Clicks: 1');
    });
  });
});

When the method is exported, you can access it directly from the returned component property of the render function.

NOTE: I don't recommend exporting methods from the component for simplicity if they were not meant to be exported. This will make them available from the outside, and callable from other components.

🔗Events

If your component dispatches an event, you can test it using the component property returned by render.

To dispatch an event, we need to import and call createEventDispatcher, and then call the returning funtion, giving it an event name and an optional value.

<!-- lib/Counter.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  const dispatch = createEventDispatcher();

  let count: number = 0;
  export const increment = () => {
    count += 1;
    dispatch('countChanged', count);
  };
</script>

<button on:click={increment}>
  Clicks: {count}
</button>
<!-- ... -->
// lib/__tests__/Counter.spec.ts
// ...

  it('it emits an event', async () => {
    const { getByText, component } = render(Counter);
    const button = getByText(/Clicks:/);
    let mockEvent = jest.fn();
    component.$on('countChanged', function (event) {
      mockEvent(event.detail);
    });
    await fireEvent.click(button);

    // Some examples on what to test
    expect(mockEvent).toHaveBeenCalled(); // to check if it's been called
    expect(mockEvent).toHaveBeenCalledTimes(1); // to check how any times it's been called
    expect(mockEvent).toHaveBeenLastCalledWith(1); // to check the content of the event
    await fireEvent.click(button);
    expect(mockEvent).toHaveBeenCalledTimes(2);
    expect(mockEvent).toHaveBeenLastCalledWith(2);
  });

//...

For this example, I updated the component to emit an event: countChanged. Every time the button is clicked, the event will emit the new count. In the test, I'm using getByText to select the button to click, and component.

Then, I'm using component.$on(eventName), and mocking the callback function to test the emitted value (event.detail).

🔗Props

You can set initial props values, and modifying them using the client-side component API.

Let's update our component to receive the initial count value.

<!-- lib/Counter.svelte -->
<script lang="ts">
// ...
  export let count: number = 0;
// ...
</script>

<!-- ... -->

Converting count to an input value requires exporting the variable declaration.

Then we can test:

  • default values
  • initial values
  • updating values
// lib/__tests__/Counter.ts
// ...
describe('count', () => {
    it('defaults to 0', async () => {
      const { getByText } = render(Counter);
      const button = getByText(/Clicks:/);
      expect(button.innerHTML).toBe('Clicks: 0');
    });

    it('can have an initial value', async () => {
      const { getByText } = render(Counter, {props: {count: 33}});
      const button = getByText(/Clicks:/);
      expect(button.innerHTML).toBe('Clicks: 33');
    });

    it('can be updated', async () => {
      const { getByText, component } = render(Counter);
      const button = getByText(/Clicks:/);
      expect(button.innerHTML).toBe('Clicks: 0');
      await component.$set({count: 41})
      expect(button.innerHTML).toBe('Clicks: 41');
    });
});
// ...

We are using the second argument of the render method to pass initial values to count, and we are testing it through the rendered button

To update the value, we are calling the $set method on component, which will update the rendered value on the next tick. That's why we need to await it.

🔗Wrapping up

Testing components using Jest and Testing Library can help you avoid errors when developing, and also can make you more confident when applying changes to an existing codebase. I hope this blog post is a step forward to better testing.

You can find these examples in this repo


This Dot Labs is a development consultancy focused on providing staff augmentation, architectural guidance, and consulting to companies.

We help implement and teach modern web best practices with technologies such as React, Angular, Vue, Web Components, GraphQL, Node, and more.

You might also like

Javascript

Getting Started with RxJS

Javascript

Testing Web Components with Cypress and TypeScript

Javascript

Web Components Integration using LitElement and TypeScript

Javascript

Navigation Lifecycle using Vaadin Router, LitElement and TypeScript