Skip to content

Testing

Assignment

Martina Hostettler reports an old bug. After a change to the rules, the app accepts requests whose first day has already passed again.

As a board member, I want a fixed bug to stay fixed, so that members and the team can rely on the rules of the app.

The volunteer finds no test in the app that checks a day in the past. The acceptance criteria are:

  1. The rules of a request have tests that notice every bug that has occurred before.
  2. Components have tests that check what someone sees and what they report.
  3. Stores have tests that set the answer of the API, without a server.

Learning goals

After this chapter you can

  • tell kinds of tests and their purpose apart,
  • test functions with Vitest,
  • test components through their behavior with Vue Test Utils,
  • test stores and composables while replacing fetch,
  • decide what's worth testing and check your tests with mutants.

The Vue documentation distinguishes three kinds. Unit tests check a function, a composable or a store on its own, and it recommends Vitest for them. Component tests mount a component and check what it renders and reports, with Vitest and Vue Test Utils. End-to-end tests drive the whole app in a real browser, and for those it names Playwright and Cypress. All the exercise projects of this course have tests of the first two kinds, which you have been starting with npm test since part 3.

A test calls a function with known values and compares its result with expect. describe groups tests, and it describes one test in a sentence. In the running case app, the rules of a request now have a test for the bug that came back.

reservation.test.js
describe('Rules of the reservation request', () => {
const check = (request, item = drill) => validateRequest({ from: '', to: '', ...request }, item, monday)
it('accepts a request from Wednesday to Saturday', () => {
expect(check({ from: '2026-11-18', to: '2026-11-21' })).toEqual({})
})
it('rejects days before the thing is back and more than seven days', () => {
expect(check({ from: '2026-11-11', to: '2026-11-14' }, tent)).toHaveProperty('from')
expect(check({ from: '2026-11-18', to: '2026-11-28' })).toHaveProperty('to')
})
// The mistake came back once after a change, this test keeps it from coming back again
it('rejects a first day in the past', () => {
expect(check({ from: '2026-10-31', to: '2026-11-04' })).toEqual({ from: 'The first day is in the past.' })
})
})

The day is an argument in the test, so the test gives the same result tomorrow as today. The second test checks ten days, and it doesn't notice a wrong limit of eight or nine days. That's why a test checks a limit on both sides, just allowed and just not allowed anymore.

Vue Test Utils mounts a component with mount, finds elements with find, fills in fields with setValue and fires events with trigger. Both return a promise, and only after await is the new state in the DOM. What a component has reported is in emitted.

level-2.test.js
async function fill(from, to) {
const wrapper = mount(RequestForm, { props: { today: '2026-11-02' } })
await wrapper.find('#from').setValue(from)
await wrapper.find('#to').setValue(to)
return wrapper
}
describe('Going further, the form', () => {
it('sends a valid request as an event', async () => {
const wrapper = await fill('2026-11-18', '2026-11-21')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('send')).toEqual([[{ from: '2026-11-18', to: '2026-11-21' }]])
})
it('does not send a request with mistakes', async () => {
const wrapper = await fill('2026-11-18', '2026-11-28')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('send')).toBeUndefined()
expect(wrapper.find('.error').text()).toBe('A loan lasts at most seven days.')
})

According to the Vue documentation, a component test checks the public interface, meaning props, slots and events, and what ends up in the DOM afterwards. It doesn't check internal variables, otherwise it breaks with every refactoring, even when the component still does what it should. A snapshot of the HTML alone doesn't tell whether the component works correctly either, according to the documentation.

A store needs a fresh Pinia for every test, as in chapter 7.2. The test replaces fetch with vi.stubGlobal by a function from vi.fn, which delivers a prepared answer and records every call. That way it checks the address, the method, the headers and the body, without any server running.

level-3.test.js
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRequestsStore } from '@/stores/requests.js'
const REQUEST = { itemId: 6, from: '2026-11-18', to: '2026-11-21' }
// The API answers with this status and body
function answer(status, body) {
const fetch = vi.fn(async () => new Response(JSON.stringify(body), { status }))
vi.stubGlobal('fetch', fetch)
return fetch
}
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.unstubAllGlobals())
describe('Challenge, the store with the API', () => {
it('sends the request as JSON with POST to the API', async () => {
const fetch = answer(201, { id: 'R-1', ...REQUEST })
await useRequestsStore().send(REQUEST)
const [url, options] = fetch.mock.calls[0]
expect(url).toBe('/api/en/reservations')
expect(options.method).toBe('POST')
expect(options.headers['Content-Type']).toBe('application/json')
expect(JSON.parse(options.body)).toEqual(REQUEST)
})

vi.unstubAllGlobals restores the real fetch after every test, otherwise the stand-in leaks into the next test.

A composable that only uses ref, computed and watch can be called directly from a test, which then checks the refs it returns, like the tests for chapter 5.4. If it uses hooks like onMounted or inject, it needs a host component, according to the Vue documentation. The documentation shows a small function for this that creates and mounts an app just for the composable.

The tests of the running case app mount the whole app with the router, Pinia and the API, and only replace fetch with a stand-in for the course API in tests/fake-api.js. A helper goes to an address, as if someone had typed it into the browser.

visit.js
// Pinia with the API, as main.js sets it up. A small app gives Pinia the API before the first
// navigation, because a guard may create a store before App is mounted.
export function createTestPinia(adapter = createHttpApi('/api/en')) {
const pinia = createPinia()
createApp({}).provide(apiKey, adapter).use(pinia)
setActivePinia(pinia)
return { pinia, adapter }
}
// Mounts App with the router, Pinia and the same API
export function mountApp({ pinia, adapter }, options = {}) {
return mount(App, { global: { plugins: [router, pinia], provide: { [apiKey]: adapter } }, ...options })
}
// Goes to an address and shows App there, as after typing the address into the browser, with a
// fresh Pinia and a stand-in for the course API. member signs someone in beforehand, as the
// session cookie of an earlier visit would.
export async function visit(path, { member = null, requests = [], ...options } = {}) {
api = fakeApi({ member, requests })
const setup = createTestPinia()
await router.push(path)
const wrapper = mountApp(setup, options)
await flushPromises()
return wrapper
}

Tests like these check how pages, stores and the router work together, and they still need no browser. They run in happy-dom, a stand-in for the browser in Node that draws nothing. Whether a button sits visibly on top of another one is something only an end-to-end test can check.

Tests pay off most for rules with limits, for error states, for what members see and for every bug that has occurred before. Such a test is best written before the fix. It fails first, and after the fix it passes. Tests that check Vue itself or a library bring little, and so do tests that pin down every internal line.

Coverage shows which lines a test ran. Vitest measures it with the package @vitest/coverage-v8 and vitest run --coverage, according to the Vitest documentation. Whether a test would notice a mistake in those lines is something coverage doesn't show. A mutation test checks exactly that. It builds small mistakes into the code, so-called mutants, and checks whether the tests fail afterwards. A mutant that passes all the tests shows a gap. Tools like StrykerJS do this for whole projects. The exercise project of this chapter comes with a small version of it.

The minimum scope asks for three automated tests, six for a pair. Choose them so that they check something important, such as a rule of your app, a component with behavior and a store with a replaced fetch. Write a test whenever you fix a bug. The screencast asks about the bug that kept you busy the longest, and a test for it shows that you understood it.

Work with the running case app in the folder case/08-architecture/en of the course repo and with the exercise project of this chapter. Write down what you expect before you run anything.

  1. Set the limit MAX_DAYS in src/features/reservations/rules.js to 9 and run the tests. Does any test notice the mistake? Why not?
  2. In the second component test of the solution, remove the await before trigger('submit'). What happens, and why?
  3. Run npm test in the exercise project before you write any tests. What does the check report?
  4. A test only compares wrapper.html() with a stored snapshot. Which mistakes does it find, and which doesn't it?

In this project you write the tests yourself. You'll find it in the folder exercises/08-architecture/testing/en of the course repo, with the rules of a request in src/rules.js, a form and a store. npm test first runs your tests against the correct code, where they have to pass. Then the tests of each level run against mutants, and npm test reports how many mistakes your tests find. With npm run test:watch only your tests run, again after every change.

Terminal window
npm install
npm test

Exercise Getting started

Write tests for validateRequest in tests/level-1.test.js. A request over seven days is valid, one over eight days isn't, a first day in the past isn't either, and neither is a last day before the first. Choose today's date in the test yourself. Your tests find all three mutants of this level.

Solution
level-1.test.js
import { describe, expect, it } from 'vitest'
import { validateRequest } from '@/rules.js'
// Monday, 2 November 2026
const today = '2026-11-02'
const check = (from, to) => validateRequest({ from, to }, today)
describe('Getting started, the rules of a request', () => {
it('accepts a request over seven days', () => {
expect(check('2026-11-18', '2026-11-25')).toEqual({})
})
it('rejects a request over eight days', () => {
expect(check('2026-11-18', '2026-11-26')).toHaveProperty('to')
})
it('rejects a first day in the past', () => {
expect(check('2026-11-01', '2026-11-04')).toHaveProperty('from')
})
it('rejects a last day before the first', () => {
expect(check('2026-11-18', '2026-11-14')).toHaveProperty('to')
})
})

Exercise Going further

Write tests for RequestForm.vue in tests/level-2.test.js. The form reports a valid request as the event send, it doesn't send a request with mistakes, and messages only appear after the first attempt to send. Your tests find all three mutants of this level.

Solution
level-2.test.js
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import RequestForm from '@/components/RequestForm.vue'
async function fill(from, to) {
const wrapper = mount(RequestForm, { props: { today: '2026-11-02' } })
await wrapper.find('#from').setValue(from)
await wrapper.find('#to').setValue(to)
return wrapper
}
describe('Going further, the form', () => {
it('sends a valid request as an event', async () => {
const wrapper = await fill('2026-11-18', '2026-11-21')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('send')).toEqual([[{ from: '2026-11-18', to: '2026-11-21' }]])
})
it('does not send a request with mistakes', async () => {
const wrapper = await fill('2026-11-18', '2026-11-28')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('send')).toBeUndefined()
expect(wrapper.find('.error').text()).toBe('A loan lasts at most seven days.')
})
it('shows messages only after sending', async () => {
const wrapper = await fill('', '')
expect(wrapper.find('.error').exists()).toBe(false)
await wrapper.find('form').trigger('submit')
expect(wrapper.findAll('.error')).toHaveLength(2)
})
})

Exercise Challenge

Write tests for the store useRequestsStore in tests/level-3.test.js, with a fresh Pinia and a replaced fetch. Check the address, method, header and body of the request, an accepted request in the list and a refusal of the API with its message. Your tests find all three mutants of this level.

Solution
level-3.test.js
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRequestsStore } from '@/stores/requests.js'
const REQUEST = { itemId: 6, from: '2026-11-18', to: '2026-11-21' }
// The API answers with this status and body
function answer(status, body) {
const fetch = vi.fn(async () => new Response(JSON.stringify(body), { status }))
vi.stubGlobal('fetch', fetch)
return fetch
}
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => vi.unstubAllGlobals())
describe('Challenge, the store with the API', () => {
it('sends the request as JSON with POST to the API', async () => {
const fetch = answer(201, { id: 'R-1', ...REQUEST })
await useRequestsStore().send(REQUEST)
const [url, options] = fetch.mock.calls[0]
expect(url).toBe('/api/en/reservations')
expect(options.method).toBe('POST')
expect(options.headers['Content-Type']).toBe('application/json')
expect(JSON.parse(options.body)).toEqual(REQUEST)
})
it('adds an accepted request to the list', async () => {
answer(201, { id: 'R-1', ...REQUEST })
const store = useRequestsStore()
expect(await store.send(REQUEST)).toBe(true)
expect(store.list).toEqual([{ id: 'R-1', ...REQUEST }])
})
it('reports a refusal and adds nothing', async () => {
answer(409, { error: 'The thing is already requested for some of these days.' })
const store = useRequestsStore()
expect(await store.send(REQUEST)).toBe(false)
expect(store.list).toEqual([])
expect(store.error).toBe('The thing is already requested for some of these days.')
})
})
MistakeWhat helps
A test always passes, even when the code is wrong.Run the test once against code that's wrong on purpose, it has to fail.
A component test checks internal variables and breaks with every refactoring.Check what the component shows and reports.
After setValue or trigger, the test still sees the old state.Put await in front, and call flushPromises after requests.
A test calls the real server and fails when it isn't running.Replace fetch with vi.stubGlobal and restore it with vi.unstubAllGlobals after the test.
Tests affect each other through a store.Create a fresh Pinia before every test with setActivePinia(createPinia()).
An AI assistant writes tests that only mirror what the code currently does.Derive the expectations from the requirements and run every test once against a mistake.

Self-check

  1. Which kinds of tests does the Vue documentation distinguish, and what do you write them with?
  2. What does a component test check, and what doesn't it?
  3. How does a test check a store that calls fetch, without a server?
  4. What does a mutant show that coverage doesn't?
Answers
  1. Unit tests and component tests with Vitest and Vue Test Utils, end-to-end tests with Playwright or Cypress.
  2. What the component renders and reports after props, slots and input. It doesn't check internal variables.
  3. With a fresh Pinia and a fetch that vi.stubGlobal replaces with a stand-in that delivers a prepared answer.
  4. Whether a test also notices a mistake in a line that ran.

Assignment

The bug with the day in the past now has a test that notices it. A little later, a member who works with a screen reader writes to the association. The number of hits in the catalog stays silent, all the "Favorite" buttons sound the same, and after the back button the focus lands in the search field instead of on the thing. The next chapter takes care of accessibility.

Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.