Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Testy composables i store Pinia

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, @name - 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, @name: 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, @name: 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.

Przejdź do CodeWorlds