Vue.js course Β· Module 9: Composables & VueUse
Testing Composables
In this lesson6
Every critical NOVA LAB station system must pass rigorous testing before deployment. The same goes for composables - we need to be sure they work correctly in every scenario. Think about scale: one composable works in ten components, so one bug in it breaks ten screens at once. A test written at the composable level catches such a bug in a single run, before the crew ever sees it.
Setting Up the Test Environment
To test composables we use Vitest, the testing tool from the Vite ecosystem, and helpers such as flushPromises come from the @vue/test-utils package. The Vue documentation distinguishes two cases. A composable that uses only ref, computed and watch can simply be called in a test. A composable with lifecycle hooks or with inject needs a host component, and that is provided by a helper function that runs it inside a test component:
1// test-utils.ts
2import { createApp, defineComponent } from 'vue'
3
4// Helper: run composable in an isolated component
5export function withSetup(composable) {
6 let result
7
8 const TestComponent = defineComponent({
9 setup() {
10 result = composable()
11 return () => {} // empty render
12 }
13 })
14
15 const app = createApp(TestComponent)
16 app.mount(document.createElement('div'))
17
18 return { result, app }
19}The test component calls the composable in setup, renders an empty function and is mounted into an element that is not on any page. document exists in the test thanks to DOM simulation, so in the Vitest configuration set test.environment to 'happy-dom' or 'jsdom'. The returned app will come in handy for unmounting.
Testing a Simple Composable
Let's start with a counter. In the test file you import the Vitest functions, the composable itself and the helper:
1import { describe, it, expect } from 'vitest'
2import { ref } from 'vue'
3import { withSetup } from './test-utils'
4
5// Composable to test
6function useCounter(initial = 0) {
7 const count = ref(initial)
8 const increment = () => count.value++
9 const decrement = () => count.value--
10 const reset = () => { count.value = initial }
11 return { count, increment, decrement, reset }
12}describe groups the tests of one composable, each it is a separate scenario, and expect(...).toBe(...) compares the result with the expected value:
1describe('useCounter', () => {
2 it('starts with initial value', () => {
3 const { result } = withSetup(() => useCounter(10))
4 expect(result.count.value).toBe(10)
5 })
6
7 it('defaults to 0', () => {
8 const { result } = withSetup(() => useCounter())
9 expect(result.count.value).toBe(0)
10 })
11
12 it('increments value', () => {
13 const { result } = withSetup(() => useCounter())
14 result.increment()
15 expect(result.count.value).toBe(1)
16 result.increment()
17 expect(result.count.value).toBe(2)
18 })
19
20 it('decrements value', () => {
21 const { result } = withSetup(() => useCounter(5))
22 result.decrement()
23 expect(result.count.value).toBe(4)
24 })
25
26 it('resets to initial value', () => {
27 const { result } = withSetup(() => useCounter(10))
28 result.increment()
29 result.increment()
30 result.reset()
31 expect(result.count.value).toBe(10)
32 })
33})Every test creates a fresh counter, so the result of one does not affect another. This composable uses only ref, so it would also work without the helper; we use it for consistency with the next examples.
Mocking Reactive Dependencies
When a composable depends on external data sources, we mock them. It's easiest when the dependency comes in as a parameter - here the fetchFn function:
1import { describe, it, expect, vi } from 'vitest'
2import { ref, nextTick } from 'vue'
3import { withSetup } from './test-utils'
4
5// Composable with fetch dependency
6function useSensorData(fetchFn) {
7 const data = ref(null)
8 const error = ref(null)
9 const loading = ref(false)
10
11 async function load(sensorId) {
12 loading.value = true
13 error.value = null
14 try {
15 data.value = await fetchFn(sensorId)
16 } catch (e) {
17 error.value = e.message
18 } finally {
19 loading.value = false
20 }
21 }
22
23 return { data, error, loading, load }
24}vi.fn() creates a fake function that remembers its calls. mockResolvedValue makes it return a promise fulfilled with the given object, and mockRejectedValue - one rejected with an error:
1describe('useSensorData', () => {
2 it('loads data from server', async () => {
3 // Mock fetch function
4 const mockFetch = vi.fn().mockResolvedValue({
5 id: 'T-01',
6 temperature: 22,
7 status: 'active'
8 })
9
10 const { result } = withSetup(() => useSensorData(mockFetch))
11
12 expect(result.loading.value).toBe(false)
13
14 await result.load('T-01')
15
16 expect(mockFetch).toHaveBeenCalledWith('T-01')
17 expect(result.data.value).toEqual({
18 id: 'T-01',
19 temperature: 22,
20 status: 'active'
21 })
22 expect(result.loading.value).toBe(false)
23 })
24
25 it('handles errors', async () => {
26 const mockFetch = vi.fn().mockRejectedValue(
27 new Error('Server unavailable')
28 )
29
30 const { result } = withSetup(() => useSensorData(mockFetch))
31
32 await result.load('T-01')
33
34 expect(result.error.value).toBe('Server unavailable')
35 expect(result.data.value).toBeNull()
36 })
37})toHaveBeenCalledWith checks the call arguments, and toEqual compares objects field by field, because toBe would require the same reference. No test touched a real server.
Testing Composables with watch
Here lies an important difference. computed is recalculated synchronously at the moment it is read, while Vue defers the watch callback until the next tick, that is until the current synchronous code has finished. That is why we add a search counter driven by watch to the filter:
1import { describe, it, expect } from 'vitest'
2import { ref, watch, nextTick, computed } from 'vue'
3import { withSetup } from './test-utils'
4
5function useSearchFilter(items) {
6 const query = ref('')
7 const searchCount = ref(0)
8
9 const filtered = computed(() => {
10 if (!query.value) return items.value
11 return items.value.filter(item =>
12 item.name.toLowerCase().includes(query.value.toLowerCase())
13 )
14 })
15
16 // watch counts query changes - its callback waits for the next tick
17 watch(query, () => {
18 searchCount.value++
19 })
20
21 return { query, filtered, searchCount }
22}Both strings are compared in lowercase, so letter case doesn't matter - you will write the test for the query 'TEMP' in the practice task. The third test below shows what nextTick is for:
1describe('useSearchFilter', () => {
2 it('filters items by query', async () => {
3 const items = ref([
4 { name: 'Temperature sensor' },
5 { name: 'Pressure sensor' },
6 { name: 'Radiation sensor' }
7 ])
8
9 const { result } = withSetup(() => useSearchFilter(items))
10
11 // Initially all items
12 expect(result.filtered.value).toHaveLength(3)
13
14 // Filtering
15 result.query.value = 'temp'
16 await nextTick()
17
18 expect(result.filtered.value).toHaveLength(1)
19 expect(result.filtered.value[0].name).toBe('Temperature sensor')
20 })
21
22 it('returns all for empty query', async () => {
23 const items = ref([{ name: 'A' }, { name: 'B' }])
24 const { result } = withSetup(() => useSearchFilter(items))
25
26 result.query.value = 'A'
27 await nextTick()
28 expect(result.filtered.value).toHaveLength(1)
29
30 result.query.value = ''
31 await nextTick()
32 expect(result.filtered.value).toHaveLength(2)
33 })
34
35 it('counts query changes only after nextTick', async () => {
36 const items = ref([{ name: 'A' }])
37 const { result } = withSetup(() => useSearchFilter(items))
38
39 result.query.value = 'A'
40 expect(result.searchCount.value).toBe(0) // watch has not run yet
41
42 await nextTick()
43 expect(result.searchCount.value).toBe(1)
44 })
45})In the first two tests filtered would give the correct result even without await nextTick(), because it is a computed. In the third one, without waiting the counter stays at zero. flushPromises from @vue/test-utils goes one step further: it waits until all pending promises have resolved.
Testing Asynchronous Composables
We test a composable that polls the server every few seconds with Vitest's fake timers, so the test doesn't actually wait. This is the code under test:
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2import { ref, onMounted, onUnmounted } from 'vue'
3import { withSetup } from './test-utils'
4
5function usePolling(fetchFn, interval = 5000) {
6 const data = ref(null)
7 const isPolling = ref(false)
8 let timerId = null
9
10 function startPolling() {
11 isPolling.value = true
12 timerId = setInterval(async () => {
13 data.value = await fetchFn()
14 }, interval)
15 }
16
17 function stopPolling() {
18 isPolling.value = false
19 if (timerId) {
20 clearInterval(timerId)
21 timerId = null
22 }
23 }
24
25 onUnmounted(stopPolling)
26
27 return { data, isPolling, startPolling, stopPolling }
28}vi.useFakeTimers() replaces setInterval with a clock that we move forward by hand, and vi.useRealTimers() restores the real one. advanceTimersByTimeAsync moves time forward and also waits for the promises returned by the timers:
1describe('usePolling', () => {
2 beforeEach(() => {
3 vi.useFakeTimers()
4 })
5
6 afterEach(() => {
7 vi.useRealTimers()
8 })
9
10 it('polls the server at specified intervals', async () => {
11 let callCount = 0
12 const mockFetch = vi.fn().mockImplementation(() => {
13 callCount++
14 return Promise.resolve({ reading: callCount * 10 })
15 })
16
17 const { result } = withSetup(() => usePolling(mockFetch, 1000))
18
19 result.startPolling()
20 expect(result.isPolling.value).toBe(true)
21
22 // Simulate time passing (the Async version also waits for promises)
23 await vi.advanceTimersByTimeAsync(3000)
24
25 expect(mockFetch).toHaveBeenCalledTimes(3)
26 expect(result.data.value).toEqual({ reading: 30 })
27
28 result.stopPolling()
29 expect(result.isPolling.value).toBe(false)
30 })After three simulated seconds the server has answered three times, and data holds the last reading. The synchronous advanceTimersByTime would also call mockFetch three times, but data would stay empty. Finally a cleanup test, in which app.unmount() triggers onUnmounted:
1 it('cleans up the timer after unmount', async () => {
2 const mockFetch = vi.fn().mockResolvedValue({ reading: 1 })
3 const { result, app } = withSetup(() => usePolling(mockFetch, 1000))
4
5 result.startPolling()
6 app.unmount() // triggers onUnmounted -> stopPolling
7
8 await vi.advanceTimersByTimeAsync(5000)
9 expect(mockFetch).not.toHaveBeenCalled()
10 expect(result.isPolling.value).toBe(false)
11 })
12})Five seconds after unmounting, nobody polled the server - the timer really is gone.
Testing Best Practices
- Isolation - each test should be independent
- Mocking - always mock external dependencies
- Asynchrony - use
nextTickfor watchers and DOM updates, andflushPromisesfor pending promises - Cleanup - check that the composable cleans up resources properly, for example with
app.unmount() - Edge cases - test empty data, errors, boundary values
My advice: design composables so that their dependencies come in as parameters, like fetchFn. A mock is then a single line, and the test doesn't need to replace the global fetch. You will extend your tests to components at the Launch Platform. In the editor below, a miniature test runner works in the browser: it checks useCounter, useSearchFilter and useAsyncData, and shows the results on the panel.
Remember: a test is a module's pressure check before launch - better to find the leak in the lab than in orbit.
Code for this lesson: App.vue
1<script setup>
2import { ref, computed, watch, nextTick } from 'vue'
3
4// ===== TESTING COMPOSABLES DEMO =====
5
6// Composable: useCounter (under test)
7function useCounter(initial = 0) {
8 const count = ref(initial)
9 const doubled = computed(() => count.value * 2)
10 const increment = () => count.value++
11 const decrement = () => count.value--
12 const reset = () => { count.value = initial }
13 return { count, doubled, increment, decrement, reset }
14}
15
16// Composable: useSearchFilter (under test)
17function useSearchFilter(items) {
18 const query = ref('')
19 const filtered = computed(() => {
20 if (!query.value) return items.value
21 return items.value.filter(item =>
22 item.name.toLowerCase().includes(query.value.toLowerCase())
23 )
24 })
25 return { query, filtered }
26}
27
28// Composable: useAsyncData (under test)
29function useAsyncData(fetchFn) {
30 const data = ref(null)
31 const error = ref(null)
32 const loading = ref(false)
33
34 async function load(...args) {
35 loading.value = true
36 error.value = null
37 try {
38 data.value = await fetchFn(...args)
39 } catch (e) {
40 error.value = e.message
41 } finally {
42 loading.value = false
43 }
44 }
45
46 return { data, error, loading, load }
47}
48
49// === Test Runner ===
50const testResults = ref([])
51const totalPassed = computed(() => testResults.value.filter(t => t.passed).length)
52const totalFailed = computed(() => testResults.value.filter(t => !t.passed).length)
53
54function assert(condition, testName) {
55 testResults.value.push({
56 name: testName,
57 passed: condition,
58 timestamp: new Date().toLocaleTimeString()
59 })
60}
61
62async function runAllTests() {
63 testResults.value = []
64
65 // Test useCounter
66 const counter = useCounter(10)
67 assert(counter.count.value === 10, 'useCounter: starts with initial value')
68 counter.increment()
69 assert(counter.count.value === 11, 'useCounter: increment works')
70 assert(counter.doubled.value === 22, 'useCounter: doubled computed works')
71 counter.decrement()
72 counter.decrement()
73 assert(counter.count.value === 9, 'useCounter: decrement works')
74 counter.reset()
75 assert(counter.count.value === 10, 'useCounter: reset returns to initial')
76
77 const defaultCounter = useCounter()
78 assert(defaultCounter.count.value === 0, 'useCounter: default initial is 0')
79
80 // Test useSearchFilter
81 const items = ref([
82 { name: 'Temperature Sensor' },
83 { name: 'Pressure Sensor' },
84 { name: 'Radiation Detector' }
85 ])
86 const filter = useSearchFilter(items)
87 assert(filter.filtered.value.length === 3, 'useSearchFilter: shows all initially')
88
89 filter.query.value = 'sensor'
90 await nextTick()
91 assert(filter.filtered.value.length === 2, 'useSearchFilter: filters by query')
92
93 filter.query.value = 'radiation'
94 await nextTick()
95 assert(filter.filtered.value.length === 1, 'useSearchFilter: exact match')
96 assert(filter.filtered.value[0].name === 'Radiation Detector', 'useSearchFilter: correct item')
97
98 filter.query.value = ''
99 await nextTick()
100 assert(filter.filtered.value.length === 3, 'useSearchFilter: empty query shows all')
101
102 // Test useAsyncData
103 const successData = useAsyncData(async () => ({ temp: 22, status: 'OK' }))
104 assert(successData.loading.value === false, 'useAsyncData: not loading initially')
105 assert(successData.data.value === null, 'useAsyncData: no data initially')
106
107 await successData.load()
108 assert(successData.data.value !== null, 'useAsyncData: has data after load')
109 assert(successData.data.value.temp === 22, 'useAsyncData: correct data')
110 assert(successData.error.value === null, 'useAsyncData: no error on success')
111
112 const failData = useAsyncData(async () => { throw new Error('Connection lost') })
113 await failData.load()
114 assert(failData.error.value === 'Connection lost', 'useAsyncData: captures error')
115 assert(failData.data.value === null, 'useAsyncData: no data on error')
116}
117</script>
118
119<template>
120 <div class="test-lab">
121 <h1>Testing Composables - NOVA LAB</h1>
122
123 <div class="controls">
124 <button @click="runAllTests" class="run-btn">Run All Tests</button>
125 </div>
126
127 <div v-if="testResults.length" class="summary">
128 <span class="passed">Passed: {{ totalPassed }}</span>
129 <span class="failed">Failed: {{ totalFailed }}</span>
130 <span class="total">Total: {{ testResults.length }}</span>
131 </div>
132
133 <div class="results">
134 <div v-for="(test, i) in testResults" :key="i"
135 class="test-result" :class="{ pass: test.passed, fail: !test.passed }">
136 <span class="icon">{{ test.passed ? 'PASS' : 'FAIL' }}</span>
137 <span class="test-name">{{ test.name }}</span>
138 </div>
139 </div>
140
141 <div v-if="!testResults.length" class="empty">
142 Click "Run All Tests" to execute test suite
143 </div>
144 </div>
145</template>
146
147<style scoped>
148.test-lab { background: #0a0e27; color: #e0e0ff; padding: 1.5rem; min-height: 100vh; font-family: 'Courier New', monospace; }
149h1 { color: #00ff88; text-align: center; margin-bottom: 2rem; }
150.controls { text-align: center; margin: 1rem 0; }
151.run-btn { background: #00ff88; color: #0a0e27; border: none; padding: 0.8rem 2rem; border-radius: 6px; font-size: 1.1rem; font-weight: bold; cursor: pointer; font-family: inherit; }
152.run-btn:hover { background: #00b4d8; color: #fff; }
153.summary { display: flex; justify-content: center; gap: 2rem; padding: 1rem; background: rgba(0,0,0,0.3); border-radius: 8px; margin: 1rem 0; font-size: 1.1rem; }
154.passed { color: #00ff88; }
155.failed { color: #ff0055; }
156.total { color: #00b4d8; }
157.results { margin: 1rem 0; }
158.test-result { display: flex; align-items: center; gap: 0.8rem; padding: 0.5rem 0.8rem; margin: 0.2rem 0; border-radius: 4px; font-size: 0.9rem; }
159.test-result.pass { background: rgba(0,255,136,0.08); border-left: 3px solid #00ff88; }
160.test-result.fail { background: rgba(255,0,85,0.08); border-left: 3px solid #ff0055; }
161.icon { font-weight: bold; font-size: 0.8rem; padding: 0.15rem 0.4rem; border-radius: 3px; }
162.pass .icon { background: #00ff88; color: #0a0e27; }
163.fail .icon { background: #ff0055; color: #fff; }
164.test-name { color: #ccc; }
165.empty { text-align: center; color: #666; padding: 3rem; font-style: italic; }
166</style>Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Why do we need the withSetup helper for testing composables?
2. How do we mock external dependencies in composable tests?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Create a test component with useSensorData. Mock fetchFn using vi.fn(). Check loading, data, error after calling load().
- Vertical ordering
Arrange the unit test steps for a composable from preparation to assertion:
- Click in order
Arrange a correct assertion checking a ref value:
- Code editor
Test useSearchFilter: filtering by query, empty query returns all items, case-insensitive matching.
- Horizontal ordering
Arrange the creation of a mock fetch function: