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