Kurs Vue.js · Moduł 9: Composables i VueUse
Testowanie Composables
W tej lekcji6
Każdy krytyczny system stacji NOVA LAB musi przejść rygorystyczne testy przed wdrożeniem. Tak samo composables - musimy mieć pewność, że działają poprawnie w każdym scenariuszu. Pomyśl o skali: jeden composable pracuje w dziesięciu komponentach, więc jeden błąd w nim psuje dziesięć ekranów naraz. Test napisany na poziomie composable łapie taki błąd jednym uruchomieniem, zanim zobaczy go załoga.
Przygotowanie środowiska testowego
Do testowania composables używamy Vitest, czyli narzędzia do testów z ekosystemu Vite, a pomocnicze funkcje, takie jak flushPromises, daje pakiet @vue/test-utils. Dokumentacja Vue rozróżnia dwa przypadki. Composable, który używa tylko ref, computed i watch, możesz po prostu wywołać w teście. Composable z hookami cyklu życia albo z inject potrzebuje komponentu-gospodarza, a tego dostarcza funkcja pomocnicza, która uruchamia go wewnątrz komponentu testowego:
1// test-utils.ts
2import { createApp, defineComponent } from 'vue'
3
4// Helper: uruchom composable w izolowanym komponencie
5export function withSetup(composable) {
6 let result
7
8 const TestComponent = defineComponent({
9 setup() {
10 result = composable()
11 return () => {} // pusty render
12 }
13 })
14
15 const app = createApp(TestComponent)
16 app.mount(document.createElement('div'))
17
18 return { result, app }
19}Komponent testowy wywołuje composable w setup, renderuje pustą funkcję i zostaje zamontowany w elemencie, którego nie ma na żadnej stronie. document istnieje w teście dzięki symulacji DOM, więc w konfiguracji Vitest ustaw test.environment na 'happy-dom' albo 'jsdom'. Zwracany app przyda się do odmontowania.
Testowanie prostego composable
Zacznijmy od licznika. W pliku testów importujesz funkcje Vitest, sam composable i helper:
1import { describe, it, expect } from 'vitest'
2import { ref } from 'vue'
3import { withSetup } from './test-utils'
4
5// Composable do testowania
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 grupuje testy jednego composable, każde it to osobny scenariusz, a expect(...).toBe(...) porównuje wynik z oczekiwaną wartością:
1describe('useCounter', () => {
2 it('startuje z wartością początkową', () => {
3 const { result } = withSetup(() => useCounter(10))
4 expect(result.count.value).toBe(10)
5 })
6
7 it('domyślnie startuje od 0', () => {
8 const { result } = withSetup(() => useCounter())
9 expect(result.count.value).toBe(0)
10 })
11
12 it('inkrementuje wartość', () => {
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('dekrementuje wartość', () => {
21 const { result } = withSetup(() => useCounter(5))
22 result.decrement()
23 expect(result.count.value).toBe(4)
24 })
25
26 it('resetuje do wartości początkowej', () => {
27 const { result } = withSetup(() => useCounter(10))
28 result.increment()
29 result.increment()
30 result.reset()
31 expect(result.count.value).toBe(10)
32 })
33})Każdy test tworzy świeży licznik, więc wynik jednego nie wpływa na drugi. Ten composable używa tylko ref, więc zadziałałby też bez helpera; używamy go dla spójności z kolejnymi przykładami.
Mockowanie reaktywnych zależności
Gdy composable zależy od zewnętrznych źródeł danych, mockujemy je. Najłatwiej, gdy zależność przychodzi jako parametr - tu funkcja fetchFn:
1import { describe, it, expect, vi } from 'vitest'
2import { ref, nextTick } from 'vue'
3import { withSetup } from './test-utils'
4
5// Composable z zależnością od fetch
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() tworzy funkcję-atrapę, która zapamiętuje swoje wywołania. mockResolvedValue każe jej zwrócić obietnicę spełnioną podanym obiektem, a mockRejectedValue - odrzuconą błędem:
1describe('useSensorData', () => {
2 it('ładuje dane z serwera', async () => {
3 // Mock funkcji fetch
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('obsługuje błędy', async () => {
26 const mockFetch = vi.fn().mockRejectedValue(
27 new Error('Serwer niedostępny')
28 )
29
30 const { result } = withSetup(() => useSensorData(mockFetch))
31
32 await result.load('T-01')
33
34 expect(result.error.value).toBe('Serwer niedostępny')
35 expect(result.data.value).toBeNull()
36 })
37})toHaveBeenCalledWith sprawdza argumenty wywołania, a toEqual porównuje obiekty pole po polu, bo toBe wymagałby tej samej referencji. Żaden test nie dotknął prawdziwego serwera.
Testowanie composables z watch
Tu kryje się ważna różnica. computed przelicza się synchronicznie w chwili odczytu, a callback watch Vue odkłada do następnego ticka, czyli do chwili, gdy skończy się bieżący synchroniczny kod. Dlatego do filtra dokładamy licznik wyszukiwań prowadzony przez watch:
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 liczy zmiany zapytania - jego callback czeka na następny tick
17 watch(query, () => {
18 searchCount.value++
19 })
20
21 return { query, filtered, searchCount }
22}Nazwa i zapytanie są porównywane małymi literami, więc wielkość liter nie ma znaczenia - test na zapytanie 'TEMP' dopiszesz w zadaniu praktycznym. Trzeci test poniżej pokazuje, po co jest nextTick:
1describe('useSearchFilter', () => {
2 it('filtruje elementy po zapytaniu', async () => {
3 const items = ref([
4 { name: 'Sensor temperatury' },
5 { name: 'Sensor ciśnienia' },
6 { name: 'Sensor radiacji' }
7 ])
8
9 const { result } = withSetup(() => useSearchFilter(items))
10
11 // Początkowo wszystkie elementy
12 expect(result.filtered.value).toHaveLength(3)
13
14 // Filtrowanie
15 result.query.value = 'temp'
16 await nextTick()
17
18 expect(result.filtered.value).toHaveLength(1)
19 expect(result.filtered.value[0].name).toBe('Sensor temperatury')
20 })
21
22 it('zwraca wszystko dla pustego zapytania', 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('liczy zmiany zapytania dopiero po 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 jeszcze nie ruszył
41
42 await nextTick()
43 expect(result.searchCount.value).toBe(1)
44 })
45})W dwóch pierwszych testach filtered dałby poprawny wynik nawet bez await nextTick(), bo to computed. W trzecim bez oczekiwania licznik zostaje na zerze. flushPromises z @vue/test-utils idzie o krok dalej: czeka, aż rozwiążą się wszystkie oczekujące obietnice.
Testowanie composables asynchronicznych
Composable odpytujący serwer co kilka sekund testujemy na fałszywych zegarach Vitest, żeby test nie czekał naprawdę. Tak wygląda badany kod:
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() podmienia setInterval na zegar, który przesuwamy ręcznie, a vi.useRealTimers() przywraca prawdziwy. advanceTimersByTimeAsync przesuwa czas i czeka też na obietnice zwrócone przez timery:
1describe('usePolling', () => {
2 beforeEach(() => {
3 vi.useFakeTimers()
4 })
5
6 afterEach(() => {
7 vi.useRealTimers()
8 })
9
10 it('odpytuje serwer w zadanych odstępach', 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 // Symulacja upływu czasu (wersja Async czeka też na obietnice)
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 })Po trzech symulowanych sekundach serwer odpowiedział trzy razy, a data trzyma ostatni odczyt. Synchroniczne advanceTimersByTime też wywołałoby mockFetch trzy razy, ale data zostałoby puste. Na koniec test sprzątania, w którym app.unmount() uruchamia onUnmounted:
1 it('sprząta timer po odmontowaniu', async () => {
2 const mockFetch = vi.fn().mockResolvedValue({ reading: 1 })
3 const { result, app } = withSetup(() => usePolling(mockFetch, 1000))
4
5 result.startPolling()
6 app.unmount() // uruchamia onUnmounted -> stopPolling
7
8 await vi.advanceTimersByTimeAsync(5000)
9 expect(mockFetch).not.toHaveBeenCalled()
10 expect(result.isPolling.value).toBe(false)
11 })
12})Pięć sekund po odmontowaniu nikt nie odpytał serwera - timer naprawdę zniknął.
Dobre praktyki testowania
- Izolacja - każdy test powinien być niezależny
- Mockowanie - zewnętrzne zależności zawsze mockowane
- Asynchroniczność - używaj
nextTickdla watcherów i aktualizacji DOM, aflushPromisesdla oczekujących obietnic - Cleanup - sprawdzaj czy composable poprawnie czyści zasoby, na przykład przez
app.unmount() - Edge cases - testuj puste dane, błędy, wartości graniczne
Moja rada: projektuj composables tak, by zależności przychodziły parametrami, jak fetchFn. Wtedy mock to jedna linijka, a test nie wymaga podmieniania globalnego fetch. Testy komponentów rozbudujesz na Platformie Startowej. W edytorze poniżej działa miniaturowy runner testów w przeglądarce: sprawdza useCounter, useSearchFilter i useAsyncData, a wyniki pokazuje na panelu.
Zapamiętaj: test to próba ciśnieniowa modułu przed startem - lepiej, żeby przeciek znalazł go w laboratorium niż na orbicie.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed, watch, nextTick } from 'vue'
3
4// ===== DEMO: TESTOWANIE COMPOSABLES =====
5
6// Composable: useCounter (testowany)
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 (testowany)
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 (testowany)
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// === Mini runner testów ===
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 // Testy useCounter
66 const counter = useCounter(10)
67 assert(counter.count.value === 10, 'useCounter: startuje od wartości początkowej')
68 counter.increment()
69 assert(counter.count.value === 11, 'useCounter: increment działa')
70 assert(counter.doubled.value === 22, 'useCounter: computed doubled działa')
71 counter.decrement()
72 counter.decrement()
73 assert(counter.count.value === 9, 'useCounter: decrement działa')
74 counter.reset()
75 assert(counter.count.value === 10, 'useCounter: reset wraca do wartości początkowej')
76
77 const defaultCounter = useCounter()
78 assert(defaultCounter.count.value === 0, 'useCounter: domyślna wartość początkowa to 0')
79
80 // Testy useSearchFilter
81 const items = ref([
82 { name: 'Czujnik temperatury' },
83 { name: 'Czujnik ciśnienia' },
84 { name: 'Detektor promieniowania' }
85 ])
86 const filter = useSearchFilter(items)
87 assert(filter.filtered.value.length === 3, 'useSearchFilter: na start pokazuje wszystko')
88
89 filter.query.value = 'czujnik'
90 await nextTick()
91 assert(filter.filtered.value.length === 2, 'useSearchFilter: filtruje według zapytania')
92
93 filter.query.value = 'promieniowania'
94 await nextTick()
95 assert(filter.filtered.value.length === 1, 'useSearchFilter: dokładne dopasowanie')
96 assert(filter.filtered.value[0].name === 'Detektor promieniowania', 'useSearchFilter: właściwy element')
97
98 filter.query.value = ''
99 await nextTick()
100 assert(filter.filtered.value.length === 3, 'useSearchFilter: puste zapytanie pokazuje wszystko')
101
102 // Testy useAsyncData
103 const successData = useAsyncData(async () => ({ temp: 22, status: 'OK' }))
104 assert(successData.loading.value === false, 'useAsyncData: na start nie ładuje')
105 assert(successData.data.value === null, 'useAsyncData: na start brak danych')
106
107 await successData.load()
108 assert(successData.data.value !== null, 'useAsyncData: po load() są dane')
109 assert(successData.data.value.temp === 22, 'useAsyncData: poprawne dane')
110 assert(successData.error.value === null, 'useAsyncData: brak błędu po sukcesie')
111
112 const failData = useAsyncData(async () => { throw new Error('Utracono połączenie') })
113 await failData.load()
114 assert(failData.error.value === 'Utracono połączenie', 'useAsyncData: przechwytuje błąd')
115 assert(failData.data.value === null, 'useAsyncData: brak danych po błędzie')
116}
117</script>
118
119<template>
120 <div class="test-lab">
121 <h1>Testowanie composables - NOVA LAB</h1>
122
123 <div class="controls">
124 <button @click="runAllTests" class="run-btn">Uruchom wszystkie testy</button>
125 </div>
126
127 <div v-if="testResults.length" class="summary">
128 <span class="passed">Zaliczone: {{ totalPassed }}</span>
129 <span class="failed">Niezaliczone: {{ totalFailed }}</span>
130 <span class="total">Razem: {{ 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 ? 'OK' : 'BŁĄD' }}</span>
137 <span class="test-name">{{ test.name }}</span>
138 </div>
139 </div>
140
141 <div v-if="!testResults.length" class="empty">
142 Kliknij "Uruchom wszystkie testy", aby wykonać zestaw testów
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>Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Dlaczego potrzebujemy helpera withSetup do testowania composables?
2. Jak mockujemy zewnętrzne zależności w testach composables?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Stwórz komponent testowy z useSensorData. Zamockuj fetchFn za pomocą vi.fn(). Sprawdź loading, data, error po użyciu load().
- Układanie w pionie
Ułóż kroki unit testu composable od przygotowania do asercji:
- Klikanie w kolejności
Ułóż poprawną asercję sprawdzającą wartość ref:
- Edytor kodu
Przetestuj useSearchFilter: filtrowanie po zapytaniu, puste zapytanie zwraca wszystko, wielkość liter nie ma znaczenia.
- Układanie w poziomie
Ułóż tworzenie mocka funkcji fetch: