Kurs Vue.js · Moduł 12: Testy i wdrożenie
Testy composables i store Pinia
W tej lekcji13
W poprzedniej lekcji montowałeś komponenty i klikałeś w ich przyciski jak technik sprawdzający panel przed startem: wciska guzik, patrzy, czy zapaliła się właściwa lampka. To dobra metoda na wszystko, co widać na obudowie. Kłopot w tym, że w panelu telemetrii NOVA LAB większość pracy dzieje się pod podłogą - w composables, które liczą i pobierają dane, oraz w store Pinia, który trzyma stan całej misji. Żeby sprawdzić jeden przelicznik, musiałbyś zamontować cały panel, poczekać na render, wyłowić liczbę z HTML-u i dopiero z niej wnioskować, czy obliczenie się zgadza.
To tak, jakbyś odpalał rakietę, żeby sprawdzić jeden zawór. W hali prób robi się inaczej: wymontowuje się podzespół, stawia go na stanowisku pomiarowym i mierzy bezpośrednio. Dokładnie to zrobimy z logiką panelu - w całej tej lekcji nie zamontujemy ani jednego komponentu.
Composable to zwykła funkcja, nie magia frameworka
Zanim napiszemy pierwszy test, nazwijmy uczciwie rzecz, którą będziemy badać. Composable nie jest żadnym specjalnym bytem frameworka: to zwykła funkcja JavaScript, która tworzy kilka refów, definiuje kilka funkcji i zwraca je w jednym obiekcie. Nie potrzebuje szablonu, nie potrzebuje instancji aplikacji, nie potrzebuje przeglądarki. W teście wywołasz ją dokładnie tak samo, jak wywołujesz ją w bloku script setup komponentu. Zaczniemy od najprostszego przyrządu w laboratorium: licznika cykli pompy, który ma wartość początkową, wartość podwojoną i dwie akcje zmieniające stan.
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}Przyjrzyj się temu, co ta funkcja oddaje na zewnątrz. Wartość count jest refem, więc w teście będziesz czytał count.value, a nie samo count. Wartość doubled to computed, czyli również ref, tylko taki, którego nigdy nie ustawiasz ręcznie, bo Vue wylicza ją z count. Ani jedna linia tego pliku nie dotyka DOM, nie sięga po window i nie korzysta z cyklu życia komponentu. To dlatego stanowisko pomiarowe wystarczy tu w najprostszej możliwej postaci.
Skoro mamy przed sobą zwykłą funkcję, obalmy od razu trzy przekonania, które krążą po zespołach. Nieprawdą jest, że composable da się sprawdzić wyłącznie wewnątrz zamontowanego komponentu - montowanie bywa potrzebne dopiero wtedy, gdy composable korzysta z haków cyklu życia w rodzaju onMounted albo z mechanizmu provide oraz inject. Nieprawdą jest również, że composables wymykają się testom jednostkowym: przeciwnie, w całym projekcie są najwdzięczniejszym materiałem na takie testy, bo nie mają ani szablonu, ani stylów. I wreszcie testy E2E, które podnoszą prawdziwą przeglądarkę i przechodzą całą ścieżkę operatora, są tu armatą wytoczoną na muchę - kosztują sekundy zamiast milisekund i nie powiedzą Ci, która linia się pomyliła.
Trzy słowa, z których zbudowany jest test
Vitest, którego skonfigurowałeś w poprzedniej lekcji, udostępnia zestaw funkcji importowanych z paczki vitest. Funkcja describe grupuje testy pod wspólną nazwą: porządkuje raport i wyznacza zasięg wspólnych przygotowań, do których wrócimy w dalszej części lekcji. Funkcja it opisuje jeden przypadek: pierwszym argumentem jest zdanie mówiące, co ma się wydarzyć, drugim funkcja, która to sprawdza. Funkcja expect przyjmuje wartość i pozwala postawić na niej warunek za pomocą matchera, czyli metody opisującej oczekiwanie. Najprostszy matcher nazywa się toBe i sprawdza, czy wartość jest dokładnie tą, której się spodziewasz. Tyle wystarczy, żeby sprawdzić obie ścieżki inicjalizacji licznika.
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})Uruchom testy i zobaczysz dwie zielone linie, każdą w kilka milisekund. Najciekawsze jest jednak to, czego w tym pliku nie ma: nie ma mount, nie ma szablonu, nie ma czekania na render. Każde it wywołuje useCounter od nowa, więc każdy przypadek dostaje własny, świeży licznik - nie musisz niczego zerować między testami, bo między testami nie ma czego zerować. Destrukturyzacja też jest tu bezpieczna: wyciągając count z obiektu, zabierasz ze sobą ref, czyli obiekt opakowujący wartość, a nie jej kopię sprzed chwili.
Akcja i wartość pochodna na jednym stanowisku
Sprawdzenie wartości początkowej to dopiero pomiar spoczynkowy. Prawdziwe pytanie brzmi: czy po wywołaniu akcji stan zmienia się tak, jak obiecuje dokumentacja podzespołu. Scenariusz jest zawsze ten sam i będziesz go powtarzał do końca lekcji: weź to, co composable zwrócił, wywołaj akcję, sprawdź wynik. Przy okazji zbadamy doubled, bo wartość computed jest przeliczana leniwie, dopiero w momencie odczytu - a odczyt następuje w chwili, w której sięgasz po doubled.value w asercji.
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})Test przeszedł bez jednego await i to jest tu najważniejsza obserwacja. W poprzedniej lekcji czekałeś na aktualizację DOM po kliknięciu, bo Vue odkłada przerysowanie interfejsu na później. Tutaj żadnego interfejsu nie ma: czytasz wartość wprost z refa, a ta zmienia się natychmiast po increment. Zwróć też uwagę, co pozostało nietknięte - plik useCounter.js nie zyskał ani jednej linii dopisanej na potrzeby testu. Dobry test niczego nie żąda od kodu produkcyjnego. Funkcji decrement celowo nie sprawdziliśmy, bo to Twoje zadanie w ćwiczeniu praktycznym, które za chwilę Cię czeka.
toBe czy toEqual, czyli dwa różne pytania
Do tej pory porównywaliśmy liczby i toBe w zupełności wystarczał. Za moment zaczniemy porównywać obiekty i tablice, a wtedy ten sam matcher potrafi zrobić przykrą niespodziankę. Matcher toBe pyta o tożsamość: czy to jest dokładnie ta sama wartość, ta sama komórka pamięci, ten sam obiekt. Dla liczb i napisów działa intuicyjnie, ale dwa obiekty o identycznej zawartości to dla niego dwie różne rzeczy, bo leżą pod dwoma różnymi adresami. Matcher toEqual pyta o co innego: czy struktura i wartości są takie same, pole po polu, w głąb zagnieżdżonych obiektów. Przyda się jeszcze przedrostek not, który odwraca każde oczekiwanie.
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})Wszystkie trzy asercje w tym teście przechodzą i razem opowiadają całą historię: te same dane, dwa różne obiekty, a tożsamość zachodzi wyłącznie wtedy, gdy porównujesz coś z samym sobą. Zapamiętaj tę różnicę dokładnie, bo bywa źródłem pomyłek. Nie jest tak, że oba matchery robią to samo, ani że toEqual jest przestarzały, a toBe szybszy - oba są aktualne, oba masz stosować, tylko do innych pytań. Nie jest też prawdą, że jeden obsługuje liczby, a drugi napisy: obie te wartości sprawdzisz jednym i drugim, bo dla wartości prostych wynik jest identyczny. Moja rekomendacja jest prosta: do liczb, napisów i wartości logicznych bierz toBe, do obiektów i tablic zawsze toEqual, a toBe na obiektach używaj tylko wtedy, gdy naprawdę chcesz udowodnić, że to ten sam egzemplarz.
Composable, który sięga po dane z serwera misji
Licznik był łatwym pacjentem, bo niczego nie potrzebował z zewnątrz. Prawdziwy panel telemetrii wygląda inaczej: żeby cokolwiek pokazać, musi zapytać serwer misji o dane. Napiszmy więc composable, który pobiera zasób spod podanego adresu i wystawia trzy refy: pobrane dane, informację o tym, czy pobieranie właśnie trwa, oraz ewentualny błąd. Wywołanie sieciowe zamkniemy w bloku try i catch, a przełącznik ładowania zgasimy w bloku finally, żeby zgasł niezależnie od tego, czy zapytanie się powiodło.
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}Ten composable jest znacznie ciekawszym obiektem badań niż licznik, bo ma dwie ścieżki: udaną i nieudaną. Ma też poważny problem od strony testowania. Wywołuje fetch, czyli wbudowaną w środowisko funkcję wysyłającą prawdziwe zapytanie sieciowe. Test, który naprawdę puka do serwera misji, jest wolny, wymaga działającego serwera i potrafi zaświecić się na czerwono z powodu przeciążonego łącza, a nie z powodu Twojego błędu. Na stanowisku prób nikt nie podłącza podzespołu do prawdziwej anteny - podłącza go do symulatora sygnału.
Atrapa funkcji, czyli vi.fn
Takim symulatorem w Vitest jest vi, obiekt narzędziowy importowany z tej samej paczki co describe i it. Jego najważniejsza metoda, vi.fn, tworzy funkcję atrapę: funkcję, która niczego nie robi, za to zapamiętuje każde swoje wywołanie razem z argumentami i potrafi zwrócić to, co jej każesz zwrócić. Do sprawdzania zapisanych wywołań służą matchery toHaveBeenCalledTimes, liczący wywołania, oraz toHaveBeenCalledWith, sprawdzający argumenty. Zobacz to na przykładzie funkcji powiadamiającej dyżurnego kontroli misji.
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})Zwróć uwagę, czego atrapa nie zrobiła: nie nawiązała żadnego połączenia, niczego nie wysłała i nigdzie nie zajrzała. To jej jedyny sens istnienia - staje w miejsce prawdziwej zależności, żeby badany kod myślał, że rozmawia ze światem, podczas gdy rozmawia z zaślepką. Warto tu rozprawić się z trzema nieporozumieniami. vi.fn nie tworzy prawdziwych połączeń z API, bo działa dokładnie odwrotnie: usuwa je z testu. Nie ma też nic wspólnego z formatowaniem wyjścia w konsoli, bo za czytelny raport odpowiada sam Vitest, a nie atrapy. I na pewno niczego nie naprawia w Twoim kodzie - żadne narzędzie testowe nie poprawia błędów, testy jedynie pokazują palcem, gdzie one siedzą.
Podstawianie globalnego fetch
Skoro atrapa potrafi udawać dowolną funkcję, to potrafi udawać także fetch. W środowisku testowym fetch jest zwykłą właściwością obiektu globalnego, dostępną pod nazwą global, więc możesz ją po prostu nadpisać. Zapis czyta się z lewej do prawej: najpierw wskazujesz cel podmiany przez global.fetch =, potem tworzysz atrapę wywołaniem vi.fn(), potem dopinasz do niej .mockResolvedValue({, czyli deklarację, że ma zwracać obietnicę rozwiązaną podaną wartością, a na końcu opisujesz udawaną odpowiedź. Prawdziwa odpowiedź serwera ma metodę json, która zwraca obietnicę z danymi, więc atrapa musi mieć ją również - stąd zapis json: async () => mockData }) domykający całość.
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})Pojawiła się tu jedna nowa funkcja: beforeEach uruchamia podany kod przed każdym testem w grupie, więc każdy przypadek zaczyna od świeżej, niczego jeszcze niepamiętającej atrapy. Zauważ, że rozbiliśmy zapis na dwa kroki - w beforeEach powstaje sama atrapa, a dopiero w teście mówimy jej, co ma zwrócić. Jeżeli wolisz mieć wszystko w jednej linii, możesz napisać global.fetch = vi.fn().mockResolvedValue({ json: async () => mockData }) bezpośrednio w teście i efekt będzie identyczny. Polecam wersję dwuetapową wszędzie tam, gdzie w jednej grupie masz kilka testów o różnych odpowiedziach, bo wspólne przygotowanie zostaje wtedy w jednym miejscu.
Po wywołaniu fetchData dane wylądowały w refie, przełącznik ładowania wrócił do wartości fałszywej dzięki blokowi finally, a błąd pozostał pusty. Porównanie danych zrobiliśmy przez toEqual, więc test przejdzie niezależnie od tego, czy atrapa oddała dokładnie ten sam obiekt, czy jego wierną kopię - a to dobrze, bo w teście interesuje nas zawartość odpowiedzi, nie jej adres w pamięci. Co się przy tym nie zmieniło: plik useFetch.js nie wie o niczym. Nie dodaliśmy do niego przełącznika trybu testowego ani parametru z podmienianą funkcją. Podmieniliśmy środowisko wokół kodu, a nie sam kod.
Zanim pójdziemy dalej, jeszcze trzy zdania o tym, jak tego nie robić. Zaimportowanie prawdziwej biblioteki w rodzaju node-fetch niczego nie rozwiązuje, bo dalej wysyłasz prawdziwe zapytania, tylko innym narzędziem. Vitest nie mockuje też żądań HTTP samoczynnie - dopóki sam nie podstawisz atrapy, fetch w teście działa naprawdę. I nie jest prawdą, że fetch jest nietykalny, więc trzeba korzystać z prawdziwego API. Właśnie go podstawiłeś jedną linią. Istnieje jeszcze metoda vi.stubGlobal, która robi to samo i potrafi automatycznie przywrócić oryginał, ale przypisanie do global.fetch jest zapisem, który spotkasz w większości projektów, i przy nim zostaniemy.
Ścieżka awaryjna też wymaga pomiaru
Test, który sprawdza wyłącznie szczęśliwy scenariusz, opisuje laboratorium, a nie misję. Na Marsie łącze potrafi zamilknąć, więc zbadajmy, co zrobi composable, gdy zapytanie się nie powiedzie. Do tego służy mockRejectedValue, bliźniacza metoda atrapy, która zamiast obietnicy spełnionej zwraca obietnicę odrzuconą podanym błędem. Sprawdzimy dwie rzeczy naraz: czy dane pozostały puste i czy w refie błędu wylądował obiekt klasy Error. Do tego drugiego pytania idealny jest matcher toBeInstanceOf, który bada, czy wartość jest egzemplarzem podanej klasy.
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})Awaria przebiegła kontrolowanie i to jest właśnie wynik, na którym Ci zależy: mimo odrzuconej obietnicy test nie wysypał się z wyjątkiem, bo composable przechwycił go w bloku catch. Zwróć uwagę na to, co się nie zmieniło mimo błędu - ref z danymi dalej trzyma wartość pustą, więc panel nie pokaże połowy klatki ani śmieci po poprzednim pobraniu. Gdybyś w composable zapisywał do refa nie cały błąd, lecz sam komunikat, matcher toBeInstanceOf nie miałby czego badać i porównywałbyś zwykły napis. To świadomy wybór projektowy i za chwilę zobaczysz w store dokładnie ten drugi wariant.
Magazyn misji, który weźmiemy na warsztat
Composables mają jedną wygodną cechę: każde wywołanie tworzy nowy, niezależny egzemplarz. Store Pinia jest inny z założenia, bo cała stacja ma korzystać z jednego wspólnego magazynu danych. Zanim napiszemy dla niego testy, spójrzmy na sam magazyn. Będzie to archiwum zdjęć powierzchni: lista rekordów w artworks, przełącznik isLoading, ref na błąd oraz obiekt filters z jednym polem wyszukiwania. Do tego jeden getter, filteredArtworks, który zwraca tylko te rekordy, których tytuł zawiera szukaną frazę, niezależnie od wielkości liter.
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})To jest ten sam zapis store, który poznałeś w lokacji o Pinii - funkcja przekazana do defineStore tworzy refy i computed, a na końcu zwraca je w obiekcie. Skoro getter jest zwykłym computed, to w teście będzie zachowywał się dokładnie tak jak doubled z licznika: przeliczy się w chwili odczytu, bez żadnego czekania. Brakuje jeszcze akcji, która wypełni archiwum danymi z serwera misji. Dopiszemy ją wewnątrz tej samej funkcji, obok gettera, i pamiętaj o dołożeniu jej nazwy do zwracanego obiektu, bo inaczej pozostanie prywatna.
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}Ta akcja różni się od fetchData z composable dwoma szczegółami, które będą miały bezpośredni wpływ na testy. Po pierwsze sprawdza właściwość ok odpowiedzi, czyli informację, czy serwer odpowiedział kodem sukcesu - jeśli nie, sama rzuca wyjątek. Po drugie do refa błędu zapisuje wyłącznie komunikat, a nie cały obiekt błędu. Pierwsza różnica oznacza, że udawana odpowiedź w teście musi mieć pole ok ustawione na wartość prawdziwą, bo inaczej akcja uzna pobranie za nieudane, zanim w ogóle sięgnie po dane.
Store bez aplikacji, czyli setActivePinia
Gdybyś teraz napisał test, który po prostu wywołuje useGalleryStore(), dostałbyś błąd o braku aktywnej instancji Pinii. To nie jest usterka, tylko konsekwencja tego, jak Pinia działa. W aplikacji gdzieś na starcie wywołujesz app.use(createPinia()) i od tej chwili biblioteka wie, w którym magazynie ma trzymać stan. W teście nie ma aplikacji, więc trzeba jej to powiedzieć wprost. Służą do tego dwie funkcje importowane z paczki pinia: createPinia tworzy nową, pustą instancję, a setActivePinia ustawia ją jako tę bieżącą.
Zapamiętaj kolejność tych kroków, bo pomyłka w niej daje właśnie tamten błąd. Najpierw importujesz setActivePinia oraz createPinia. Potem w beforeEach wywołujesz setActivePinia(createPinia()), żeby każdy test dostał czystą instancję. Dopiero wtedy w teście tworzysz instancję store. Następnie wywołujesz jego akcję. I na koniec sprawdzasz stan asercjami. Zobaczmy tę sekwencję w najprostszym możliwym teście, badającym stan początkowy archiwum.
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})Pusta tablica jest obiektem, więc porównujemy ją przez toEqual - toBe szukałby tego samego egzemplarza tablicy i zapaliłby się na czerwono mimo poprawnego kodu. Zauważ też, że stan czytamy przez kropkę, prosto ze store, bez destrukturyzacji i bez storeToRefs. W komponencie sięgałeś po storeToRefs, żeby nie stracić reaktywności przy rozbijaniu store na kawałki. W teście nic nie rozbijasz i niczego nie renderujesz, więc odczyt przez kropkę jest zarówno najkrótszy, jak i najczytelniejszy. Nowa instancja Pinii przed każdym testem gwarantuje przy tym, że dane z jednego przypadku nie przeciekną do następnego.
Akcja fetchArtworks pod obserwacją
Teraz połączymy obie umiejętności z tej lekcji: aktywną instancję Pinii i podstawioną atrapę fetch. Scenariusz jest taki, jak wcześniej - przygotuj udawaną odpowiedź, utwórz store, wywołaj akcję, sprawdź stan. Pamiętaj o polu ok w udawanej odpowiedzi, bo akcja bada je przed sięgnięciem po dane. Każdy blok pokazuję jako kompletny plik, żebyś mógł go skopiować i uruchomić bez domyślania się, co powinno znaleźć się na górze.
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})Sprawdzamy tu dwie rzeczy naraz i obie są ważne. Pierwsza asercja mówi, że dane z odpowiedzi trafiły do stanu store bez żadnych zmian po drodze. Druga, pozornie błaha, pilnuje bloku finally: gdyby ktoś kiedyś usunął z akcji gaszenie przełącznika, panel telemetrii zostałby na zawsze z kręcącym się kółkiem ładowania, a ten test od razu by o tym doniósł. Zwróć uwagę, że nigdzie nie podaliśmy adresu serwera - atrapa odpowiada tak samo na każde wywołanie fetch, więc test nie zależy od tego, czy zasób nazywa się /api/frames, czy inaczej.
Getter na ławie pomiarowej
Do pełnego obrazu brakuje jeszcze filtrowania, a ono ma w teście miłą właściwość: nie potrzebuje ani sieci, ani atrapy. Skoro artworks jest zwykłym refem wystawionym przez store, możesz przypisać do niego gotową listę wprost w teście, bez wywoływania akcji. Potem ustawiasz frazę w filters.search i czytasz filteredArtworks. Do sprawdzenia długości listy przyda się matcher toHaveLength, który jest czytelniejszy od porównywania wartości pola z długością.
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})Getter zadziałał natychmiast po zmianie frazy, bez await i bez odświeżania czegokolwiek, dokładnie jak doubled w liczniku. Najcenniejsze jest jednak to, czego ten test nie ruszył: lista w store.artworks dalej ma trzy pozycje. Filtrowanie tworzy nową tablicę na potrzeby odczytu i nie usuwa niczego ze stanu - gdyby kiedyś zaczęło usuwać, ta lekcja i ten test przestałyby się zgadzać, a to właśnie sygnał, że coś w kodzie poszło nie tak. Ustawianie danych ręcznie, z pominięciem akcji, jest w testach getterów celowe: badasz jedno zachowanie naraz, więc nie wciągasz do testu całej ścieżki sieciowej.
Odcinanie sąsiednich modułów przez vi.mock
Zostaje ostatni przypadek z prawdziwego projektu. Nasz store archiwum bywa uzależniony od sąsiada - na przykład od store autoryzacji, z którego bierze token dostępowy do serwera misji. W teście archiwum nie chcesz przechodzić przez logowanie, bo badasz co innego. Do podmiany całego modułu, a nie pojedynczej funkcji, służy vi.mock. Pierwszym argumentem jest ścieżka do modułu, liczona względem pliku testowego, a drugim funkcja fabryki zwracająca obiekt z tym, co ten moduł ma udawać, że eksportuje.
1import { vi } from 'vitest'
2
3vi.mock('./auth', () => ({
4 useAuthStore: () => ({
5 token: 'fake-token',
6 isAuthenticated: true
7 })
8}))Od tego momentu każdy import z modułu auth w tym pliku testowym dostanie atrapę zamiast prawdziwego store, więc badany kod zobaczy zalogowanego użytkownika z gotowym tokenem. Dwie rzeczy warto zapamiętać. Po pierwsze Vitest przenosi wywołania vi.mock na samą górę pliku, ponad importy - dlatego działa niezależnie od tego, w którym miejscu je zapiszesz, ale też dlatego fabryka nie może korzystać ze zmiennych zadeklarowanych niżej w pliku. Po drugie zwracany obiekt musi być opakowany w nawias okrągły, bo bez niego strzałkowa funkcja potraktowałaby klamrę jako początek swojego ciała, a nie jako obiekt. Po vi.mock sięgaj oszczędnie i tylko do prawdziwych zależności między modułami, bo im więcej modułów udajesz, tym mniej Twój test mówi o tym, jak system zachowa się naprawdę.
Masz teraz komplet przyrządów z hali prób: composable wywołujesz wprost i czytasz jego refy, sieć zastępujesz atrapą, store budzisz do życia przez setActivePinia(createPinia()), a sąsiednie moduły odcinasz przez vi.mock. W następnej lekcji poskładamy te podzespoły z powrotem w całość i sprawdzimy, czy współpracują ze sobą w zamontowanym panelu.
Pamiętaj: composable i store to podzespoły, które w hali prób bada się osobno - rakietę odpalasz dopiero wtedy, gdy każdy z nich pokazał już właściwy odczyt na stanowisku.
Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - zarzadzanie zmiennymi srodowiskowymi
5const environments = ref({
6 development: {
7 API_URL: 'http://localhost:3000',
8 MARS_API_KEY: 'dev_key_12345',
9 DEBUG_MODE: 'true',
10 LOG_LEVEL: 'debug'
11 },
12 staging: {
13 API_URL: 'https://staging-mars.novalab.com',
14 MARS_API_KEY: 'staging_key_98765',
15 DEBUG_MODE: 'true',
16 LOG_LEVEL: 'info'
17 },
18 production: {
19 API_URL: 'https://mars.novalab.com',
20 MARS_API_KEY: 'prod_key_******',
21 DEBUG_MODE: 'false',
22 LOG_LEVEL: 'error'
23 }
24})
25
26const currentEnv = ref('development')
27const selectedVar = ref(null)
28
29const currentConfig = computed(() => {
30 return environments.value[currentEnv.value]
31})
32
33const securityScore = computed(() => {
34 const config = currentConfig.value
35 let score = 100
36
37 if (config.DEBUG_MODE === 'true' && currentEnv.value === 'production') {
38 score -= 30
39 }
40 if (config.MARS_API_KEY.includes('dev') && currentEnv.value === 'production') {
41 score -= 40
42 }
43 if (config.API_URL.includes('localhost') && currentEnv.value !== 'development') {
44 score -= 30
45 }
46
47 return Math.max(0, score)
48})
49
50function switchEnvironment(env) {
51 currentEnv.value = env
52 selectedVar.value = null
53}
54
55function showVarInfo(varName) {
56 selectedVar.value = varName
57}
58</script>
59
60<template>
61 <div class="env-control">
62 <div class="header">
63 <h1>Panel zmiennych srodowiskowych</h1>
64 <p>Konfiguracja misji NOVA LAB</p>
65 </div>
66
67 <div class="env-selector">
68 <button
69 v-for="env in ['development', 'staging', 'production']"
70 :key="env"
71 @click="switchEnvironment(env)"
72 :class="{ active: currentEnv === env }"
73 >
74 {{ env.toUpperCase() }}
75 </button>
76 </div>
77
78 <div class="config-display">
79 <h2>Konfiguracja: {{ currentEnv.toUpperCase() }}</h2>
80
81 <div class="var-list">
82 <div
83 v-for="(value, key) in currentConfig"
84 :key="key"
85 class="var-item"
86 @click="showVarInfo(key)"
87 :class="{ selected: selectedVar === key }"
88 >
89 <span class="var-name">{{ key }}</span>
90 <span class="var-value">{{ value }}</span>
91 </div>
92 </div>
93
94 <div v-if="selectedVar" class="var-info">
95 <h3>{{ selectedVar }}</h3>
96 <p class="info-text">
97 <template v-if="selectedVar === 'API_URL'">
98 Bazowy URL endpointow API misji marsjanskiej. Inny dla kazdego srodowiska.
99 </template>
100 <template v-else-if="selectedVar === 'MARS_API_KEY'">
101 Klucz uwierzytelniania do danych z Marsa. Na produkcji musi pozostac tajny!
102 </template>
103 <template v-else-if="selectedVar === 'DEBUG_MODE'">
104 Wlacza szczegolowe logowanie. Na produkcji powinien miec wartosc false.
105 </template>
106 <template v-else-if="selectedVar === 'LOG_LEVEL'">
107 Minimalny poziom logow: debug, info, warn, error.
108 </template>
109 </p>
110 </div>
111 </div>
112
113 <div class="security-panel">
114 <h2>Stan bezpieczenstwa</h2>
115 <div class="security-score" :class="securityScore >= 80 ? 'safe' : 'danger'">
116 <span>Wynik bezpieczenstwa:</span>
117 <strong>{{ securityScore }}/100</strong>
118 </div>
119 <div class="status-message">
120 <template v-if="securityScore === 100">
121 Konfiguracja jest bezpieczna i zoptymalizowana!
122 </template>
123 <template v-else-if="securityScore >= 70">
124 Wykryto drobne problemy z bezpieczenstwem
125 </template>
126 <template v-else>
127 Krytyczne problemy z bezpieczenstwem! Sprawdz konfiguracje!
128 </template>
129 </div>
130 </div>
131 </div>
132</template>
133
134<style scoped>
135.env-control {
136 background: #0a0e27;
137 color: #00ff88;
138 padding: 2rem;
139 min-height: 100vh;
140 font-family: 'Courier New', monospace;
141}
142
143.header {
144 text-align: center;
145 margin-bottom: 2rem;
146 border-bottom: 2px solid #00b4d8;
147 padding-bottom: 1rem;
148}
149
150.header h1 {
151 color: #00ff88;
152 text-shadow: 0 0 10px #00ff88;
153 margin: 0;
154}
155
156.header p {
157 color: #00b4d8;
158 margin: 0.5rem 0 0 0;
159}
160
161.env-selector {
162 display: flex;
163 gap: 1rem;
164 justify-content: center;
165 margin-bottom: 2rem;
166}
167
168.env-selector button {
169 padding: 0.75rem 2rem;
170 background: rgba(0, 180, 216, 0.1);
171 border: 2px solid #00b4d8;
172 color: #00b4d8;
173 cursor: pointer;
174 border-radius: 4px;
175 font-family: 'Courier New', monospace;
176 font-size: 1rem;
177 transition: all 0.3s;
178}
179
180.env-selector button.active {
181 background: #00b4d8;
182 color: #0a0e27;
183 box-shadow: 0 0 20px #00b4d8;
184}
185
186.config-display, .security-panel {
187 background: rgba(0, 180, 216, 0.1);
188 border: 1px solid #00b4d8;
189 border-radius: 8px;
190 padding: 1.5rem;
191 margin-bottom: 1.5rem;
192}
193
194.config-display h2, .security-panel h2 {
195 color: #00b4d8;
196 margin-top: 0;
197}
198
199.var-list {
200 display: flex;
201 flex-direction: column;
202 gap: 0.75rem;
203}
204
205.var-item {
206 display: flex;
207 justify-content: space-between;
208 padding: 1rem;
209 background: rgba(0, 255, 136, 0.05);
210 border: 1px solid transparent;
211 border-radius: 4px;
212 cursor: pointer;
213 transition: all 0.3s;
214}
215
216.var-item:hover, .var-item.selected {
217 border-color: #00ff88;
218 background: rgba(0, 255, 136, 0.1);
219}
220
221.var-name {
222 color: #00b4d8;
223 font-weight: bold;
224}
225
226.var-value {
227 color: #00ff88;
228 font-family: monospace;
229}
230
231.var-info {
232 margin-top: 1.5rem;
233 padding: 1rem;
234 background: rgba(0, 255, 136, 0.1);
235 border-left: 4px solid #00ff88;
236 border-radius: 4px;
237}
238
239.var-info h3 {
240 color: #00ff88;
241 margin-top: 0;
242}
243
244.info-text {
245 color: #fff;
246 line-height: 1.6;
247}
248
249.security-score {
250 display: flex;
251 justify-content: space-between;
252 padding: 1rem;
253 border-radius: 4px;
254 margin-bottom: 1rem;
255}
256
257.security-score.safe {
258 background: rgba(0, 255, 136, 0.2);
259 border: 2px solid #00ff88;
260}
261
262.security-score.danger {
263 background: rgba(255, 0, 0, 0.2);
264 border: 2px solid #ff0000;
265}
266
267.security-score strong {
268 font-size: 1.5rem;
269}
270
271.status-message {
272 text-align: center;
273 padding: 1rem;
274 font-size: 1.1rem;
275}
276</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. Jak testować composable w Vitest?
2. Do czego służy vi.fn() w Vitest?
To 2 z 4 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Napisz testy dla composable useCounter sprawdzające inicjalizację, increment i computed doubled.
- Układanie w pionie
Uporządkuj kroki testowania Pinia store:
- Klikanie w kolejności
Ułóż składnię mockowania modułu:
- Edytor kodu
Napisz testy dla Pinia store sprawdzające stan początkowy, akcję fetchArtworks i filtrowanie.
- Układanie w poziomie
Ułóż składnię mockowania pomyślnej odpowiedzi fetch: