Skip to content

Unit tests with Vitest

Exercise unit-tests-vitest-annex.pdf · PDF

Somebody deletes {disabled} from the <button> in Button.svelte. The button still looks dimmed, because the class is still there. A user clicks it, and it creates a new note. When did this break? Who noticed? Nobody.

How do you check today that your app still works after a change? You click around. Every button, every state, after every change? With three components, maybe. With thirty, never. A bug you find today costs a minute. The same bug found by a user costs your Saturday.

What you will learn

  • Explain what a unit test is, and why clicking by hand does not scale
  • Set up Vitest, jsdom and Testing Library in a SvelteKit project
  • Write a test in three steps: arrange, act, assert
  • Find elements the way a user does, and choose between getBy, queryBy and findBy
  • Test props, events, exported methods and time
  • The trap: green does not mean right. Prove a test works by making it fail on purpose

Quality control

A LEGO brick that does not grip is caught at the factory, by a machine, before it goes in a box. Nobody opens each box at the shop to try the bricks. Our factory is src/lib/components/. On this page we build the machine.

A unit test is code that checks one small piece of the program automatically: it sets up a situation, does something, and states what must be true. Here, the unit is one component.

Every test has the same three steps:

Step What you do In a test of Button
Arrange Set the scene Put a disabled Button on the page
Act Do what a user does Click it
Assert State what must be true onclick was not called

If you did JUnit in first year, you know this: @Test, assertEquals. Same idea, other syntax.

Tests are not only for big companies

A component is used in ten screens. One test file protects all ten. The smaller the team, the less time there is to click around.

The tools

Tool Its job Who / when
Vitest The test runner: finds *.test.js files, runs them, reports green or red. It shares Vite's config, so it understands .svelte files Anthony Fu and the Vite team; first published 3 December 2021
jsdom A simulated browser page inside Node: a DOM (the tree of elements of a page) with no screen. Fast, but it cannot draw
Testing Library Puts the component on that page (render) and finds things the way a user does (screen.getByRole) Kent C. Dodds, 2018
user-event Simulates a real user: user.click, user.type, user.keyboard Part of Testing Library
jest-dom Readable assertions: toBeInTheDocument, toHaveTextContent, toHaveClass Part of Testing Library

Testing Library has one guiding principle, and you will see it everywhere on this page:

"The more your tests resemble the way your software is used, the more confidence they can give you."

Install and configure

1. Install.

npm install -D vitest jsdom @testing-library/svelte @testing-library/jest-dom @testing-library/user-event

2. Change vite.config.js. Three changes: defineConfig now comes from vitest/config, there is one more plugin, and there is a test block. Your sveltekit(...) call stays exactly as it was.

vite.config.js
import adapter from '@sveltejs/adapter-auto';
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import { defineConfig } from 'vitest/config';

export default defineConfig({
    plugins: [
        sveltekit({
            compilerOptions: {
                // Force runes mode for the project, except for libraries. Can be removed in svelte 6.
                runes: ({ filename }) =>
                    filename.split(/[/\\]/).includes('node_modules') ? undefined : true
            },
            adapter: adapter()
        }),
        svelteTesting()
    ],
    test: {
        environment: 'jsdom',
        setupFiles: ['./vitest-setup.js'],
        include: ['src/**/*.test.js']
    }
});

3. Add the setup file, next to package.json. One line: it adds the jest-dom assertions to every test.

vitest-setup.js
import '@testing-library/jest-dom/vitest';

4. Add a script to package.json, inside "scripts":

"test": "vitest run"

Run npm test. It says no test files were found. Correct: there are none yet.

A red underline on toBeInTheDocument

If your editor or type checker says it "does not exist on type Assertion", add "types": ["@testing-library/jest-dom/vitest"] to compilerOptions in jsconfig.json. The tests run fine either way.

The first four tests

Four promises of the Button, four tests:

src/lib/components/Button.test.js
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { createRawSnippet } from 'svelte';
import { describe, expect, test, vi } from 'vitest';
import Button from './Button.svelte';

/** A snippet made in plain JavaScript: the test's version of <Button>Save</Button>. */
const label = (/** @type {string} */ text) =>
    createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));

describe('Button', () => {
    test('renders its label as a button', () => {
        render(Button, { children: label('Save') });

        expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
    });

    test('is primary and medium by default', () => {
        render(Button, { children: label('Save') });

        expect(screen.getByRole('button')).toHaveClass('primary', 'md');
    });

    test('calls onclick once per click', async () => {
        const user = userEvent.setup();
        const onclick = vi.fn();
        render(Button, { children: label('Save'), onclick });

        await user.click(screen.getByRole('button', { name: 'Save' }));

        expect(onclick).toHaveBeenCalledOnce();
    });

    test('does not call the click handler when disabled', async () => {
        const user = userEvent.setup();
        const onclick = vi.fn();
        render(Button, { children: label('Share'), onclick, disabled: true });

        await user.click(screen.getByRole('button', { name: 'Share' }));

        expect(onclick).not.toHaveBeenCalled();
    });
});
Piece Meaning
render(Button, { ...props }) The second argument is the props object. This is <Button ...>, written in JavaScript
label(...) A test file is not a .svelte file, so it cannot write <Button>Save</Button>. createRawSnippet builds the children snippet by hand. Copy this helper; do not memorise it
vi.fn() A mock function: a fake that remembers every call. You pass it as onclick, then ask it whether it was called
await user.click(...) A real click takes time (pointer down, up, click). Hence async and await
The blank lines The three steps. Arrange. Act. Assert

Now break it

Remember the bug at the top of this page? Delete {disabled} from the <button> and run npm test:

 × does not call the click handler when disabled
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times
 Tests  1 failed | 3 passed (4)

Who noticed this time? The machine, in seconds, and it tells you which promise was broken. Put {disabled} back: four green.

The trap: green does not mean right

Green means the promises you wrote down are kept. A test you have never seen fail may be testing nothing. So always break the code once and watch it go red. Change the default variant to 'secondary', run the tests, read the message, repair.

Finding things

Two rules decide how you look for an element.

Role first. getByRole('button', { name: 'Save' }) finds what a user finds: a button called Save. querySelector('.btn') finds a class name no user will ever see. Rename the class and that test breaks for nothing. Break the label and it does not notice.

queryBy for absence. getBy... fails when it finds nothing. So to assert that something is not there, use queryBy..., which returns null.

Query If it finds nothing Use it for
getByRole, getByText Fails at once Something that must be there now
queryByRole, queryByText Returns null Something that must not be there
findByRole, findByText Waits (1000 ms by default), then fails Something that will appear soon. Use it with await

Our weakest test

toHaveClass('primary', 'md') checks that the class is set, not that the button looks primary. Does a user see classes? No. And jsdom cannot see colours. In Visual tests and docs, Playwright can.

Plan before you code

A test plan is a table: for each promise of the spec, what you arrange, what the user does, and what must be true. Write it before the code, because it forces you to think about what the component promises.

Two things are easy to forget. Defaults are promises too: "no Pinned label, no Delete button" is part of what a plain row promises. And so is what must not happen: clicking Delete must not open the note.

src/lib/components/NoteRow.test.js
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import NoteRow from './NoteRow.svelte';

test('shows the title and, by default, nothing else', () => {
    render(NoteRow, { title: 'Shopping list' });

    expect(screen.getByText('Shopping list')).toBeInTheDocument();
    expect(screen.queryByText('Pinned')).not.toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
});

test('shows the Pinned label when pinned', () => {
    render(NoteRow, { title: 'Shopping list', pinned: true });

    expect(screen.getByText('Pinned')).toBeInTheDocument();
});

test('tells the parent when Delete is clicked', async () => {
    const user = userEvent.setup();
    const ondelete = vi.fn();
    const onopen = vi.fn();
    render(NoteRow, { title: 'Shopping list', ondelete, onopen });

    await user.click(screen.getByRole('button', { name: 'Delete Shopping list' }));

    expect(ondelete).toHaveBeenCalledOnce();
    expect(onopen).not.toHaveBeenCalled();
});

When it goes wrong

What you see Why
getByRole('button', { name: 'Delete' }) finds nothing The accessible name (what a screen reader announces) comes from the aria-label: "Delete Shopping list". Read the error: Testing Library prints every role and name it can see
The test says "0 calls" You forgot await before user.click, so the assertion ran before the click finished
getByText('Pinned') with not. throws getBy fails before not gets a chance. Use queryByText
The test calls ondelete() itself, then asserts it was called It tests the test. The user must click
expect(component.pinned).toBe(true) It copies the implementation. Assert what is on the page

Methods and time

UndoBar brings two new problems: a method to call, and five seconds nobody wants to wait.

src/lib/components/UndoBar.test.js
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { tick } from 'svelte';
import { afterEach, expect, test, vi } from 'vitest';
import UndoBar from './UndoBar.svelte';

afterEach(() => {
    vi.useRealTimers();
});

test('is empty until show() is called', async () => {
    const { component } = render(UndoBar);
    expect(screen.queryByText('Shopping list deleted')).not.toBeInTheDocument();

    component.show('Shopping list deleted');
    await tick();

    expect(screen.getByRole('status')).toHaveTextContent('Shopping list deleted');
});

test('Undo calls onundo and hides the bar', async () => {
    const user = userEvent.setup();
    const onundo = vi.fn();
    const { component } = render(UndoBar, { onundo });
    component.show('Shopping list deleted');
    await tick();

    await user.click(screen.getByRole('button', { name: 'Undo' }));

    expect(onundo).toHaveBeenCalledOnce();
    expect(screen.queryByText('Shopping list deleted')).not.toBeInTheDocument();
});

test('hides itself after duration milliseconds', async () => {
    vi.useFakeTimers();
    const { component } = render(UndoBar, { duration: 3000 });
    component.show('Shopping list deleted');
    await tick();

    vi.advanceTimersByTime(2999);
    await tick();
    expect(screen.getByText('Shopping list deleted')).toBeInTheDocument();

    vi.advanceTimersByTime(1);
    await tick();
    expect(screen.queryByText('Shopping list deleted')).not.toBeInTheDocument();
});
  • render gives back component: the same reference bind:this gives the page. component.show(...) is the page giving the order.
  • await tick(): Svelte updates the page a moment after state changes, and tick waits for that moment. After a user.click you do not need it. After calling a method yourself, you do.
  • vi.useFakeTimers(): the test owns the clock. At 2999 ms the bar is still there. One millisecond more and it is gone. A three-second promise checked in milliseconds, at the exact boundary.
  • afterEach(... useRealTimers): give the clock back, or the next test inherits a frozen one.

Run npm test: ten tests, in a few seconds. How long would it take you to check those ten things by hand, every time you change a line?

Cheat sheet

Code Meaning
render(Comp, { a: 1 }) Put the component on the simulated page with those props. Returns { component, container, ... }
screen.getByRole('button', { name: 'Save' }) Find the one button a user would call Save, or fail
screen.queryByText('Pinned') Find it or return null. For "is not there"
await screen.findByText('Saved') Wait for it to appear, or fail
const user = userEvent.setup() A simulated user: await user.click(el), await user.type(el, 'milk'), await user.keyboard('{Enter}')
vi.fn() A fake function that records its calls: toHaveBeenCalledOnce(), toHaveBeenCalledWith(3)
await tick() Wait for Svelte to update the page after a state change you caused directly
vi.useFakeTimers(), vi.advanceTimersByTime(ms) Own the clock, then move it
npx vitest Watch mode: reruns the affected tests on every save. npx vitest run runs once

Key takeaways

  • A unit test checks one piece automatically, in three steps: arrange, act, assert.
  • Vitest runs the tests, jsdom fakes the page, Testing Library renders and finds things the way a user does.
  • Look for elements by role first. Use queryBy to prove something is absent.
  • vi.fn() proves an event fired. Fake timers check time without waiting for it.
  • jsdom is blind: no layout, no colours, no real focus ring.
  • Green means the written promises hold. Break the code once to see that a test can go red.

Quick check

1. What are the three steps of a test, in order?
  • Render, click, compare
  • Arrange, act, assert
  • Install, configure, run
  • Plan, build, review

Arrange sets the scene, act does what a user does, and assert states what must be true. Blank lines between them make a test easy to read.

2. Why can a test in jsdom not tell you that a button has the wrong colour?
  • Testing Library removes all CSS before each test
  • Vitest only runs tests written in plain JavaScript
  • jsdom simulates a page but never draws anything
  • Colours can only be read through an exported method

jsdom is a simulated page with no screen, so there is no layout and no colour to look at. It is fast and blind, which is why visual tests exist as well.

3. You want to assert that a Delete button is not on the page. Which query do you use?
  • getByRole, because it is the query to try first
  • findByRole, because the button may appear later
  • querySelector, because it looks for a class name
  • queryByRole, because it returns null if nothing is found

getBy fails as soon as it finds nothing, before not gets a chance. queryBy returns null, so not.toBeInTheDocument() can do its job.

4. Why do we prefer getByRole('button', { name: 'Save' }) to querySelector('.btn')?
  • It finds what a user finds, so the test survives a renamed class
  • It runs much faster on pages with a lot of elements
  • It is the only query that works with .svelte files
  • It also checks that the button has the right colours

Users see roles and names, not classes. A test that looks by role keeps working when you tidy the CSS, and it fails when the label really breaks.

5. What does vi.fn() give you in a test?
  • A real click on the element you pass to it
  • A copy of the component to run in isolation
  • A fake function that records every call it receives
  • A clock that the test can move forward by itself

A mock function does nothing except remember. You pass it as a prop, make the user click, and then ask: were you called, and how many times?

6. Your suite is all green. What has it proved?
  • The component has no bugs left in it
  • The promises you wrote down in tests are kept
  • The component matches the Figma design exactly
  • Every test is able to catch a real bug

Green only covers the written promises. A test you have never seen fail may test nothing, so break the code once and watch it go red.

7. A component shows a message that hides itself after 10 seconds. How do you test it without waiting 10 seconds?
  • Pass a shorter duration and call await tick() in a loop
  • Assert that the setTimeout function exists in the component
  • Skip that test, because time cannot be tested automatically
  • Use fake timers and advance the clock up to the exact limit

With vi.useFakeTimers() the test owns the clock. Advance it to just under 10000 ms and the message is there; add 1 ms and it is gone. Give the clock back afterwards with vi.useRealTimers().

8. A Counter shows a number and calls onchange with the new value when you click Add. Which test checks the promise best?
  • Click Add as a user, then assert the mock was called with the new number
  • Call onchange(1) from the test, then assert the mock was called
  • Read the internal count variable, then assert that it has changed
  • Find the button by its class, then assert that the class is still set

The user must click, and the assertion must look at what the outside world sees. Calling the handler yourself only tests the test, and reading an internal variable copies the implementation.

Ten green tests, and yet you could delete all the CSS and they would stay green. In Visual tests and docs, you give the machine eyes.