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,queryByandfindBy - 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.
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.
4. Add a script to package.json, inside "scripts":
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:
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.
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.
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();
});
rendergives backcomponent: the same referencebind:thisgives the page.component.show(...)is the page giving the order.await tick(): Svelte updates the page a moment after state changes, andtickwaits for that moment. After auser.clickyou 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
queryByto 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 returnsnullif 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
.sveltefiles - 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
setTimeoutfunction 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
countvariable, 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.