In the previous lesson you mounted components and clicked their buttons like a technician checking a panel before launch: press the switch, watch to see whether the right lamp comes on. That is a fine method for everything mounted on the casing. The trouble is that in the NOVA LAB telemetry panel most of the work happens under the floor - inside composables that calculate and fetch data, and inside the Pinia store that holds the state of the entire mission. To verify a single conversion you would have to assemble the whole panel, wait for the render, fish the number back out of the HTML and only then work backwards to whether the arithmetic was right.
That is like firing the rocket to check one valve. In the test hall we do it differently: the subassembly comes out of the machine, goes onto a measuring bench, and gets measured directly. That is exactly what we are going to do with the panel logic, @name - in this entire lesson we will not mount a single component.
Before we write the first test, let us name honestly the thing we are about to examine. A composable is not some special construct of the framework: it is an ordinary JavaScript function that creates a few refs, defines a few functions, and returns them together in one object. It needs no template, no application instance, no browser. In a test you call it in exactly the same way you call it inside a component's script setup block. We will start with the simplest instrument in the laboratory: a pump cycle counter that has an initial value, a doubled value, and two actions that change the state.
1// composables/useCounter.js
2import { ref, computed } from 'vue'
3
4export function useCounter(initialValue = 0) {
5 const count = ref(initialValue)
6 const doubled = computed(() => count.value * 2)
7
8 function increment() {
9 count.value++
10 }
11
12 function decrement() {
13 count.value--
14 }
15
16 return { count, doubled, increment, decrement }
17}Look closely at what this function hands to the outside world. The
count value is a ref, so in a test you will be reading count.value rather than count itself. The doubled value is a computed, which is also a ref, just one you never set by hand, because Vue derives it from count. Not one line of this file touches the DOM, reaches for window, or relies on the component lifecycle. That is precisely why the measuring bench here can be as plain as it gets.Since what we have in front of us is an ordinary function, let us knock down three beliefs that circulate in teams. It is not true that a composable can only be checked inside a mounted component - mounting becomes necessary only when the composable uses lifecycle hooks such as onMounted, or the provide and inject mechanism. It is equally untrue that composables escape unit testing: on the contrary, in the whole project they are the most rewarding material for unit tests, because they carry neither a template nor styles. And E2E tests, which raise a real browser and walk the entire operator path, are a cannon wheeled out against a fly here - they cost seconds instead of milliseconds, and they will not tell you which line got the sum wrong.
Vitest, which you configured in the previous lesson, exposes a set of functions imported from the vitest package. The
describe function groups tests under a shared name: it tidies up the report and marks the reach of the shared preparation steps we will come back to later in this lesson. The it function describes a single case: the first argument is a sentence saying what is supposed to happen, the second is a function that checks it. The expect function takes a value and lets you place a condition on it through a matcher, which is simply a method describing an expectation. The simplest matcher is called toBe and checks whether the value is exactly the one you were expecting. That is enough to verify both initialization paths of the counter.1// composables/useCounter.spec.js
2import { describe, it, expect } from 'vitest'
3import { useCounter } from './useCounter'
4
5describe('useCounter', () => {
6 it('initializes with default value', () => {
7 const { count } = useCounter()
8
9 expect(count.value).toBe(0)
10 })
11
12 it('initializes with custom value', () => {
13 const { count } = useCounter(10)
14
15 expect(count.value).toBe(10)
16 })
17})Run the tests and you will see two green lines, each of them a few milliseconds long. The most interesting part, though, is what this file does not contain: there is no
mount, no template, no waiting for a render. Each it calls useCounter from scratch, so every case gets its own fresh counter - you do not have to reset anything between tests, because between tests there is nothing to reset. The destructuring is safe here too: by pulling count out of the object you take the ref with you, that is the object wrapping the value, and not a copy of what the value happened to be a moment ago.Checking the initial value is only the reading at rest. The real question is whether the state changes after an action exactly the way the subassembly documentation promises. The scenario is always the same and you will repeat it until the end of this lesson: take what the composable returned, call the action, check the result. While we are at it we will examine
doubled, because a computed value is recalculated lazily, only at the moment it is read - and the read happens the instant you reach for doubled.value inside the assertion.1// composables/useCounter.spec.js
2import { describe, it, expect } from 'vitest'
3import { useCounter } from './useCounter'
4
5describe('useCounter actions', () => {
6 it('increments the counter and refreshes the computed value', () => {
7 const { count, doubled, increment } = useCounter()
8
9 expect(doubled.value).toBe(0)
10
11 increment()
12
13 expect(count.value).toBe(1)
14 expect(doubled.value).toBe(2)
15 })
16})The test passed without a single
await, and that is the key observation here. In the previous lesson you waited for the DOM to update after a click, because Vue postpones repainting the interface. Here there is no interface at all: you read the value straight out of the ref, and that changes the instant increment runs. Notice as well what stayed untouched - the useCounter.js file did not gain one single line written for the sake of the test. A good test demands nothing from production code. We deliberately left the decrement function unchecked, because that is your job in the practice exercise waiting for you in a moment.So far we have been comparing numbers, and
toBe was entirely sufficient. In a moment we will start comparing objects and arrays, and then that same matcher is capable of a nasty surprise. The toBe matcher asks about identity: is this exactly the same value, the same cell of memory, the same object, the way the strict equality operator === understands it. For numbers and strings it behaves intuitively, but two objects with identical contents are two different things to it, because they sit at two different addresses. The toEqual matcher asks something else: are the structure and the values the same, field by field, all the way down through nested objects. One more thing will come in handy, the not prefix, which reverses any expectation.1import { describe, it, expect } from 'vitest'
2
3describe('toBe versus toEqual', () => {
4 it('compares frames by value and by identity', () => {
5 const first = { id: 1, title: 'Olympus Mons' }
6 const second = { id: 1, title: 'Olympus Mons' }
7
8 expect(first).toEqual(second)
9 expect(first).not.toBe(second)
10 expect(first).toBe(first)
11 })
12})All three assertions in this test pass, and together they tell the whole story: the same data, two different objects, and identity holds only when you compare something with itself. Memorise that difference precisely, because it is a common source of mistakes. It is not the case that both matchers do the same thing, nor that
toEqual is deprecated while toBe is the faster one - both are current, you are meant to use both, just for different questions. Nor is it true that one of them handles numbers and the other strings: you can check both kinds of value with either, because for primitive values the result is identical. My recommendation is simple, @name: for numbers, strings and booleans reach for toBe, for objects and arrays always use toEqual, and use toBe on objects only when you genuinely want to prove that it is the very same instance.The counter was an easy patient, because it needed nothing from outside. A real telemetry panel looks different: to display anything at all, it has to ask the mission server for data. So let us write a composable that fetches a resource from a given address and exposes three refs: the fetched data, the information about whether a fetch is currently in progress, and any error that occurred. We will wrap the network call in a try and catch block, and switch the loading flag off inside a finally block, so that it goes out regardless of whether the request succeeded.
1// composables/useFetch.js
2import { ref } from 'vue'
3
4export function useFetch(url) {
5 const data = ref(null)
6 const loading = ref(false)
7 const error = ref(null)
8
9 async function fetchData() {
10 loading.value = true
11 error.value = null
12
13 try {
14 const response = await fetch(url)
15 data.value = await response.json()
16 } catch (e) {
17 error.value = e
18 } finally {
19 loading.value = false
20 }
21 }
22
23 return { data, loading, error, fetchData }
24}This composable is a far more interesting object of study than the counter, because it has two paths: the successful one and the failed one. It also has a serious problem from the testing side. It calls
fetch, the function built into the environment that sends a genuine network request. A test that really knocks on the door of the mission server is slow, requires a running server, and can light up red because of a congested link rather than because of a mistake in your code. On the test bench nobody plugs a subassembly into a real antenna - they plug it into a signal simulator.That simulator in Vitest is
vi, a utility object imported from the same package as describe and it. Its most important method, vi.fn, creates a mock function: a function that does nothing, but remembers every call it received along with the arguments, and can return whatever you tell it to return. To inspect the recorded calls you use the toHaveBeenCalledTimes matcher, which counts calls, and toHaveBeenCalledWith, which checks the arguments. See it on the example of a function that notifies the mission control officer on duty.1import { describe, it, expect, vi } from 'vitest'
2
3describe('vi.fn', () => {
4 it('records every call it receives', () => {
5 const notify = vi.fn()
6
7 notify('telemetry ready')
8
9 expect(notify).toHaveBeenCalledTimes(1)
10 expect(notify).toHaveBeenCalledWith('telemetry ready')
11 })
12})Notice what the stand-in did not do: it opened no connection, sent nothing anywhere, and looked nowhere. That is its entire reason for existing - it stands in the place of a real dependency so that the code under test believes it is talking to the world, while it is in fact talking to a plug. It is worth settling three misunderstandings here. Creating real API connections is not what
vi.fn does, because it works in exactly the opposite direction: it removes them from the test. It also has nothing to do with formatting test output in the console, because the readable report is produced by Vitest itself, not by mock functions. And it certainly does not automatically fix anything in your code - no testing tool corrects errors, tests merely point a finger at where the errors are sitting.If a mock function can impersonate any function, then it can impersonate
fetch as well. In a test environment fetch is an ordinary property of the global object, reachable under the name global, so you can simply overwrite it. The line reads from left to right: first you name the target of the swap with global.fetch =, then you create the mock with a call to vi.fn(), then you attach .mockResolvedValue({ to it, which is the declaration that it should return a promise resolved with the given value, and finally you describe the pretend response. A real server response has a json method that returns a promise carrying the data, so the mock has to have one too - hence the json: async () => mockData }) fragment that closes the whole thing.1// composables/useFetch.spec.js
2import { describe, it, expect, vi, beforeEach } from 'vitest'
3import { useFetch } from './useFetch'
4
5describe('useFetch', () => {
6 beforeEach(() => {
7 global.fetch = vi.fn()
8 })
9
10 it('stores the payload returned by the mission server', async () => {
11 const mockData = { id: 1, title: 'Olympus Mons' }
12
13 global.fetch.mockResolvedValue({
14 json: async () => mockData
15 })
16
17 const { data, loading, error, fetchData } = useFetch('/api/frames/1')
18
19 expect(data.value).toBe(null)
20
21 await fetchData()
22
23 expect(data.value).toEqual(mockData)
24 expect(loading.value).toBe(false)
25 expect(error.value).toBe(null)
26 })
27})One new function turned up here:
beforeEach runs the given code before every test in the group, so each case starts with a fresh mock that remembers nothing yet. Notice that we split the line into two steps - the mock itself is created in beforeEach, and only inside the test do we tell it what to return. If you prefer to have everything on one line, you can write global.fetch = vi.fn().mockResolvedValue({ json: async () => mockData }) directly in the test and the effect will be identical. I recommend the two-step version everywhere you have several tests with different responses inside one group, because then the shared preparation stays in a single place.After
fetchData ran, the data landed in the ref, the loading flag went back to false thanks to the finally block, and the error stayed empty. We compared the data with toEqual, so the test will pass regardless of whether the mock handed back exactly the same object or a faithful copy of it - and that is a good thing, because in this test we care about the contents of the response, not about its address in memory. Here is what did not change along the way: the useFetch.js file knows nothing about any of this. We did not add a test mode flag to it, nor a parameter carrying a replaceable function. We swapped the environment around the code, not the code itself.Before we move on, three sentences about how not to do this. Importing a real library such as node-fetch solves nothing, because you are still sending real requests, merely with a different tool. Vitest does not mock HTTP requests by itself either - until you put a mock in place yourself,
fetch in a test genuinely runs. And it is not true that fetch is untouchable and that you therefore have to use a real API. You have just swapped it out in a single line. There is also a vi.stubGlobal method which does the same thing and can restore the original automatically, but assignment to global.fetch is the form you will meet in most projects, and that is the one we will stay with.A test that checks only the happy path describes a laboratory, not a mission. On Mars the link can go silent, so let us examine what the composable does when the request fails. That is what
mockRejectedValue is for, the twin method of a mock function, which returns a promise rejected with the given error instead of a fulfilled one. We will check two things at once: whether the data stayed empty, and whether an object of class Error landed in the error ref. For that second question the ideal matcher is toBeInstanceOf, which examines whether a value is an instance of the given class.1// composables/useFetch.spec.js
2import { describe, it, expect, vi, beforeEach } from 'vitest'
3import { useFetch } from './useFetch'
4
5describe('useFetch on a broken link', () => {
6 beforeEach(() => {
7 global.fetch = vi.fn()
8 })
9
10 it('keeps data empty and records the error', async () => {
11 global.fetch.mockRejectedValue(new Error('Network error'))
12
13 const { data, error, fetchData } = useFetch('/api/frames/1')
14
15 await fetchData()
16
17 expect(data.value).toBe(null)
18 expect(error.value).toBeInstanceOf(Error)
19 })
20})The failure ran its course under control, and that is exactly the result you were after: despite the rejected promise the test did not blow up with an exception, because the composable caught it in the catch block. Pay attention to what did not change in spite of the error - the data ref still holds the empty value, so the panel will not show half a frame or leftovers from a previous fetch. If in the composable you wrote only the message to the ref rather than the whole error object, the
toBeInstanceOf matcher would have nothing to examine and you would be comparing a plain string instead. That is a deliberate design choice, and in the store you are about to see precisely the other variant.Composables have one convenient property: every call creates a new, independent instance. A Pinia store is different by design, because the whole station is meant to use one shared repository of data. Before we write tests for it, let us look at the store itself. It will be an archive of surface photographs: a list of records in
artworks, an isLoading flag, a ref for the error, and a filters object with a single search field. On top of that one getter, filteredArtworks, which returns only those records whose title contains the searched phrase, regardless of letter case.1// stores/gallery.js
2import { defineStore } from 'pinia'
3import { ref, computed } from 'vue'
4
5export const useGalleryStore = defineStore('gallery', () => {
6 const artworks = ref([])
7 const isLoading = ref(false)
8 const error = ref(null)
9 const filters = ref({ search: '' })
10
11 const filteredArtworks = computed(() => {
12 const phrase = filters.value.search.toLowerCase()
13
14 return artworks.value.filter(
15 (item) => item.title.toLowerCase().includes(phrase)
16 )
17 })
18
19 return { artworks, isLoading, error, filters, filteredArtworks }
20})This is the same style of store you met in the location about Pinia - the function passed to
defineStore creates refs and computed values, and at the end returns them in an object. Since the getter is an ordinary computed, in a test it will behave exactly like doubled from the counter: it recalculates the moment it is read, with no waiting involved. What is still missing is the action that will fill the archive with data from the mission server. We will add it inside that same function, next to the getter, and do remember to put its name into the returned object, because otherwise it stays private.1// stores/gallery.js - inside the defineStore callback
2async function fetchArtworks() {
3 isLoading.value = true
4 error.value = null
5
6 try {
7 const response = await fetch('/api/frames')
8
9 if (!response.ok) {
10 throw new Error('Mission API refused the request')
11 }
12
13 artworks.value = await response.json()
14 } catch (e) {
15 error.value = e.message
16 } finally {
17 isLoading.value = false
18 }
19}This action differs from
fetchData in the composable in two details that will bear directly on the tests. First, it checks the ok property of the response, that is the information about whether the server answered with a success code - and if it did not, it throws an error itself. Second, it writes only the message into the error ref, not the whole error object. The first difference means that the pretend response in a test must have its ok field set to true, because otherwise the action will judge the fetch a failure before it even reaches for the data.If you now wrote a test that simply called
useGalleryStore(), you would get an error about there being no active Pinia instance. That is not a defect, it is a consequence of how Pinia works. In an application you call app.use(createPinia()) somewhere at startup, and from that moment the library knows which repository to keep the state in. In a test there is no application, so you have to tell it explicitly. Two functions imported from the pinia package do the job: createPinia creates a new, empty instance, and setActivePinia sets it as the current one.Memorise the order of these steps, because getting it wrong is what produces that very error. First you import
setActivePinia and createPinia. Then in beforeEach you call setActivePinia(createPinia()), so that every test gets a clean instance. Only then do you create the store instance inside the test. Next you call one of its actions. And finally you verify the state with assertions. Let us see that sequence in the simplest test imaginable, one that examines the initial state of the archive.1// stores/gallery.spec.js
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import { useGalleryStore } from './gallery'
5
6describe('Gallery store', () => {
7 beforeEach(() => {
8 setActivePinia(createPinia())
9 })
10
11 it('starts with an empty archive', () => {
12 const store = useGalleryStore()
13
14 expect(store.artworks).toEqual([])
15 expect(store.isLoading).toBe(false)
16 })
17})An empty array is an object, so we compare it with
toEqual - toBe would go looking for the very same array instance and would light up red despite perfectly correct code. Notice as well that we read the state through a dot, straight off store, with no destructuring and no storeToRefs. In a component you reached for storeToRefs so as not to lose reactivity when breaking the store into pieces. In a test you are breaking nothing apart and rendering nothing, so reading through a dot is both the shortest and the clearest option. The new Pinia instance created before every test guarantees at the same time that data from one case cannot leak into the next.Now we combine both skills from this lesson: an active Pinia instance and a swapped-in
fetch mock. The scenario is the one from earlier - prepare the pretend response, create the store, call the action, check the state. Remember the ok field in the pretend response, because the action inspects it before it reaches for the data. I am showing each block as a complete file, so that you can copy it and run it without having to guess what belongs at the top.1// stores/gallery.spec.js
2import { describe, it, expect, beforeEach, vi } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import { useGalleryStore } from './gallery'
5
6describe('Gallery store fetching', () => {
7 beforeEach(() => {
8 setActivePinia(createPinia())
9 })
10
11 it('loads frames from the mission API', async () => {
12 const mockArtworks = [
13 { id: 1, title: 'Olympus Mons', artist: 'Rover Ares' },
14 { id: 2, title: 'Valles Marineris', artist: 'Orbiter Nova' }
15 ]
16
17 global.fetch = vi.fn().mockResolvedValue({
18 ok: true,
19 json: async () => mockArtworks
20 })
21
22 const store = useGalleryStore()
23
24 await store.fetchArtworks()
25
26 expect(store.artworks).toEqual(mockArtworks)
27 expect(store.isLoading).toBe(false)
28 })
29})We are checking two things at once here and both of them matter. The first assertion says that the data from the response reached the store state without being altered on the way. The second one, seemingly trivial, stands guard over the finally block: if somebody ever removed the flag reset from the action, the telemetry panel would be left forever with a spinning loading wheel, and this test would report it immediately. Notice that we never gave the server address anywhere - the mock answers every
fetch call the same way, so the test does not depend on whether the resource is called /api/frames or something else.To complete the picture we are still missing the filtering, and in a test it has a pleasant property: it needs neither the network nor a mock. Since
artworks is an ordinary ref exposed by the store, you can assign a ready-made list to it directly in the test, without calling any action. Then you set the phrase in filters.search and read filteredArtworks. To check the length of the list the toHaveLength matcher comes in handy, and it reads better than comparing the value of a length property.1// stores/gallery.spec.js
2import { describe, it, expect, beforeEach } from 'vitest'
3import { setActivePinia, createPinia } from 'pinia'
4import { useGalleryStore } from './gallery'
5
6describe('Gallery store filtering', () => {
7 beforeEach(() => {
8 setActivePinia(createPinia())
9 })
10
11 it('narrows the archive down to matching titles', () => {
12 const store = useGalleryStore()
13
14 store.artworks = [
15 { id: 1, title: 'Olympus Mons', artist: 'Rover Ares' },
16 { id: 2, title: 'Valles Marineris', artist: 'Orbiter Nova' },
17 { id: 3, title: 'Olympus Ridge', artist: 'Rover Ares' }
18 ]
19
20 store.filters.search = 'olympus'
21
22 expect(store.filteredArtworks).toHaveLength(2)
23 expect(store.filteredArtworks[0].title).toBe('Olympus Mons')
24 })
25})The getter reacted the instant the phrase changed, with no
await and without refreshing anything, exactly like doubled in the counter. The most valuable part, though, is what this test did not disturb: the list in store.artworks still holds three entries. Filtering builds a new array for the purpose of the read and removes nothing from the state - if it ever started removing things, this lesson and this test would stop agreeing with each other, and that is precisely the signal that something in the code has gone wrong. Setting the data by hand and bypassing the action is deliberate in getter tests: you examine one behaviour at a time, so you do not drag the whole network path into the test.One case from a real project remains. Our archive store can end up depending on a neighbour - on an authentication store, for instance, from which it takes the access token for the mission server. In a test of the archive you do not want to go through logging in, because you are examining something else. To replace an entire module rather than a single function you use
vi.mock. The first argument is the path to the module, counted relative to the test file, and the second is a factory function returning an object with whatever that module is meant to pretend it exports.1import { vi } from 'vitest'
2
3vi.mock('./auth', () => ({
4 useAuthStore: () => ({
5 token: 'fake-token',
6 isAuthenticated: true
7 })
8}))From that moment on, every import from the auth module inside this test file will receive the mock instead of the real store, so the code under test will see a logged-in user holding a ready token. Two things are worth remembering. First, Vitest hoists
vi.mock calls to the very top of the file, above the imports - which is why it works no matter where you write it, but also why the factory cannot use variables declared further down the file. Second, the returned object has to be wrapped in round brackets, because without them the arrow function would treat the brace as the start of its own body rather than as an object. Reach for vi.mock sparingly and only for genuine dependencies between modules, because the more modules you fake, the less your test says about how the system will actually behave.You now hold the full set of instruments from the test hall: a composable you call directly and read its refs, the network you replace with a mock, a store you bring to life with
setActivePinia(createPinia()), and neighbouring modules you cut off with vi.mock. In the next lesson we will put these subassemblies back together and check whether they cooperate inside a mounted panel.Remember, @name: a composable and a store are subassemblies that the test hall examines separately - you fire the rocket only once each of them has shown the right reading on the bench.