Kurs Vue.js · Moduł 12: Testy i wdrożenie

Testy composables i store Pinia

19 min czytania
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. 1. Jak testować composable w Vitest?

  2. 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:

Przydatne artykuły