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

Testing Vue Components

6 min czytania
W tej lekcji9

Witaj na Platformie Startowej NOVA LAB! Rok 2087 - przed wysłaniem systemów na Marsa, każdy kod musi przejść rygorystyczne testy. Podobnie jak systemy rakietowe są sprawdzane przed startem, tak testujemy nasze aplikacje Vue przed wdrożeniem.

Panel telemetrii sprawdziłeś ręcznie: każdy przycisk działa. Za tydzień ktoś z zespołu poprawi jeden komponent, a przycisk "+" przestanie działać w miejscu, którego nikt już nie kliknie. Ręczne sprawdzanie się nie skaluje. Test automatyczny to procedura przedstartowa zapisana w kodzie - uruchamiasz ją jednym poleceniem przed każdym startem.

Czego się nauczysz

Na Platformie Startowej przygotujesz aplikację do lotu:

  • pisać testy komponentów z Vitest i Vue Test Utils,
  • testować composables i store Pinia bez montowania interfejsu,
  • sprawdzać współpracę modułów w testach integracyjnych i E2E,
  • budować wersję produkcyjną i wdrażać ją na hosting,
  • automatyzować cały proces w pipeline CI/CD.

Po co testy i jakie mają rodzaje

Testy zapewniają jakość kodu, wykrywają błędy wcześnie i ułatwiają refactoring: zmieniasz wnętrze komponentu, a zielone testy potwierdzają, że zachowanie się nie zmieniło. Rodzaje testów układają się w piramidę. Na dole leżą testy jednostkowe (unit tests) jednej funkcji lub komponentu - najszybsze i najtańsze, więc piszesz ich najwięcej. Wyżej stoją testy integracyjne, sprawdzające kilka części naraz. Na szczycie są testy end-to-end (E2E), klikające po prawdziwej aplikacji w przeglądarce - najwolniejsze i najdroższe, więc wystarczy ich kilka dla najważniejszych ścieżek.

Vitest - Framework Testowy

Vitest to nowoczesny framework testowy dla Vue, polecany przez dokumentację Vue w projektach opartych na Vite:

  • Szybki jak Vite
  • Kompatybilny z Jest
  • Natywne ESM
  • TypeScript out-of-the-box

Szybkość bierze się stąd, że Vitest korzysta z tej samej konfiguracji i przetwarzania plików co Vite, więc od razu rozumie pliki .vue, a API zgodne z Jest sprawia, że describe, it i expect wyglądają znajomo.

Instalacja

Do Vitest dokładamy dwie paczki: Vue Test Utils (@vue/test-utils), oficjalną bibliotekę do montowania i testowania komponentów Vue w izolacji, oraz happy-dom, który symuluje DOM przeglądarki w Node.js:

1npm install -D vitest @vue/test-utils happy-dom

Flaga -D zapisuje paczki w devDependencies - narzędzia laboratorium nie trafią do buildu produkcyjnego. Wtyczkę @vitejs/plugin-vue projekt na Vite już ma.

Konfiguracja

W pliku vitest.config.js sekcja test włącza globals: true, dzięki czemu describe, it i expect działają bez importu, oraz environment: 'happy-dom', czyli symulowany DOM:

1// vitest.config.js
2import { defineConfig } from 'vitest/config'
3import vue from '@vitejs/plugin-vue'
4
5export default defineConfig({
6  plugins: [vue()],
7  test: {
8    globals: true,
9    environment: 'happy-dom'
10  }
11})

Sekcję test możesz też dopisać do vite.config.js; gdy istnieją oba pliki, wygrywa vitest.config.js. Zostają skrypty w package.json:

1// package.json
2{
3  "scripts": {
4    "test": "vitest",
5    "test:ui": "vitest --ui",
6    "test:coverage": "vitest --coverage"
7  }
8}

Polecenie vitest działa w trybie obserwacji i powtarza testy po każdej zmianie, a w CI uruchamia je jednorazowo. vitest --ui otwiera panel w przeglądarce i wymaga paczki @vitest/ui. vitest --coverage mierzy pokrycie kodu testami, czyli procent instrukcji, gałęzi, funkcji i linii kodu źródłowego wykonanych przez testy; potrzebuje paczki @vitest/coverage-v8.

Testowany komponent

Sprawdzać będziemy prosty licznik z przyciskami + i - oraz propsem initialCount:

1<!-- Counter.vue -->
2<template>
3  <div class="counter">
4    <p>Count: {{ count }}</p>
5    <button class="increment" @click="count++">+</button>
6    <button class="decrement" @click="count--">-</button>
7  </div>
8</template>
9
10<script setup>
11import { ref } from 'vue'
12
13const props = defineProps({
14  initialCount: {
15    type: Number,
16    default: 0
17  }
18})
19
20const count = ref(props.initialCount)
21</script>

Licznik trzyma stan w ref, a prop jest tylko wartością startową. Klasy przycisków posłużą testom za selektory.

Pierwszy Test

Test buduje się z trzech funkcji Vitest: describe grupuje przypadki pod wspólną nazwą, it opisuje jeden przypadek, a expect stawia warunek na wyniku za pomocą matchera, na przykład toContain. Funkcja mount z Vue Test Utils montuje komponent i zwraca wrapper - obiekt, przez który czytasz tekst metodą text() i szukasz elementów metodą find():

1// components/Counter.spec.js
2import { describe, it, expect } from 'vitest'
3import { mount } from '@vue/test-utils'
4import Counter from './Counter.vue'
5
6describe('Counter', () => {
7  it('renders count', () => {
8    const wrapper = mount(Counter)
9    expect(wrapper.text()).toContain('Count: 0')
10  })

Test montuje licznik bez propsów i sprawdza jego tekst - bez przeglądarki, w symulowanym DOM. Drugi przypadek symuluje kliknięcie: find przyjmuje selektor CSS, a trigger('click') wysyła zdarzenie do znalezionego przycisku:

1  // Counter.spec.js - dalszy ciąg describe('Counter')
2  it('increments count on button click', async () => {
3    const wrapper = mount(Counter)
4
5    await wrapper.find('button.increment').trigger('click')
6
7    expect(wrapper.text()).toContain('Count: 1')
8  })

Słowo await jest konieczne: Vue aktualizuje DOM asynchronicznie, a trigger zwraca obietnicę spełnianą dopiero po przerysowaniu. Bez niego asercja przeczytałaby stary tekst Count: 0. Trzeci przypadek przekazuje prop w polu props obiektu opcji, czyli drugiego argumentu mount:

1  // Counter.spec.js - dalszy ciąg describe('Counter')
2  it('accepts initial count prop', () => {
3    const wrapper = mount(Counter, {
4      props: {
5        initialCount: 10
6      }
7    })
8
9    expect(wrapper.text()).toContain('Count: 10')
10  })
11})

Ostatni nawias zamyka blok describe. Zwróć uwagę, czego test nie sprawdza: nie zagląda do zmiennej count, tylko do tekstu widocznego dla użytkownika, więc przetrwa zmianę implementacji.

Testowanie zdarzeń

Komponenty także emitują zdarzenia. Metoda emitted() zwraca obiekt wszystkich wyemitowanych zdarzeń, a emitted('select') tablicę emisji jednego zdarzenia, w której każda emisja jest tablicą argumentów. Samo wrapper.trigger klika główny element komponentu:

1import { mount } from '@vue/test-utils'
2import ArtworkCard from './ArtworkCard.vue'
3
4describe('ArtworkCard', () => {
5  it('emits select event on click', async () => {
6    const artwork = {
7      id: 1,
8      title: 'Mona Lisa',
9      artist: 'Leonardo'
10    }
11
12    const wrapper = mount(ArtworkCard, {
13      props: { artwork }
14    })
15
16    await wrapper.trigger('click')
17
18    expect(wrapper.emitted()).toHaveProperty('select')
19    expect(wrapper.emitted('select')[0]).toEqual([artwork])
20  })
21
22  it('emits favorite event on heart click', async () => {
23    const wrapper = mount(ArtworkCard, {
24      props: {
25        artwork: { id: 1, title: 'Test' }
26      }
27    })
28
29    await wrapper.find('.favorite-btn').trigger('click')
30
31    expect(wrapper.emitted('favorite')).toBeTruthy()
32  })
33})

Wyrażenie emitted('select')[0] to pierwsza emisja, a matcher toEqual porównuje jej zawartość, nie tożsamość obiektu - więcej o tym w następnej lekcji. Gdy zdarzenie nie padło, emitted('favorite') zwraca undefined, więc toBeTruthy() sprawdza, że wystąpiło. Brak importu describe, it i expect nie przeszkadza dzięki globals: true.

Testowanie slotów

Treść slotów przekazujesz w opcji slots: kluczem jest nazwa slotu, a wartością fragment HTML. Slot bez nazwy nazywa się default:

1import { mount } from '@vue/test-utils'
2import BaseCard from './BaseCard.vue'
3
4describe('BaseCard', () => {
5  it('renders default slot', () => {
6    const wrapper = mount(BaseCard, {
7      slots: {
8        default: '<p>Test content</p>'
9      }
10    })
11
12    expect(wrapper.html()).toContain('Test content')
13  })
14
15  it('renders named slots', () => {
16    const wrapper = mount(BaseCard, {
17      slots: {
18        header: '<h2>Header</h2>',
19        default: '<p>Body</p>',
20        footer: '<footer>Footer</footer>'
21      }
22    })
23
24    expect(wrapper.find('h2').text()).toBe('Header')
25    expect(wrapper.find('p').text()).toBe('Body')
26    expect(wrapper.find('footer').text()).toBe('Footer')
27  })
28})

Pierwszy przypadek sprawdza cały HTML metodą html(), drugi porównuje tekst konkretnych elementów matcherem toBe. Komponent BaseCard nie wie, że działa w teście - dostaje sloty tak jak od rodzica.

Moja rada: testuj zachowanie widoczne z zewnątrz - tekst, zdarzenia i sloty - a nie wewnętrzne zmienne komponentu, bo taki test przeżyje refactoring. W następnej lekcji zejdziesz pod podłogę panelu i sprawdzisz composables oraz store Pinia.

Zapamiętaj: test to procedura przedstartowa zapisana w kodzie - każdy moduł przechodzi ją, zanim poleci na Marsa.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - demo konfiguracji buildu
5const buildConfig = ref({
6  mode: 'production',
7  minify: true,
8  sourceMap: false,
9  chunkSize: 500,
10  compression: 'gzip'
11})
12
13const buildStats = computed(() => {
14  let size = 1200 // bazowy rozmiar w KB
15  if (buildConfig.value.minify) size *= 0.6
16  if (buildConfig.value.compression === 'gzip') size *= 0.3
17  if (buildConfig.value.compression === 'brotli') size *= 0.25
18  return Math.round(size)
19})
20
21const optimizationScore = computed(() => {
22  let score = 0
23  if (buildConfig.value.mode === 'production') score += 25
24  if (buildConfig.value.minify) score += 25
25  if (!buildConfig.value.sourceMap) score += 20
26  if (buildConfig.value.chunkSize <= 500) score += 15
27  if (buildConfig.value.compression !== 'none') score += 15
28  return score
29})
30
31function toggleMinify() {
32  buildConfig.value.minify = !buildConfig.value.minify
33}
34
35function toggleSourceMap() {
36  buildConfig.value.sourceMap = !buildConfig.value.sourceMap
37}
38
39function setCompression(type) {
40  buildConfig.value.compression = type
41}
42</script>
43
44<template>
45  <div class="launch-control">
46    <div class="header">
47      <h1>NOVA LAB - konfiguracja buildu</h1>
48      <p>Strefa Wyrzutni - Platforma Startowa</p>
49    </div>
50
51    <div class="config-panel">
52      <h2>Ustawienia buildu</h2>
53
54      <div class="setting">
55        <label>
56          <input type="checkbox" v-model="buildConfig.minify" @change="toggleMinify">
57          Minifikacja (kompresja wlaczona)
58        </label>
59      </div>
60
61      <div class="setting">
62        <label>
63          <input type="checkbox" v-model="buildConfig.sourceMap" @change="toggleSourceMap">
64          Source maps (tryb debugowania)
65        </label>
66      </div>
67
68      <div class="setting">
69        <label>Typ kompresji:</label>
70        <select v-model="buildConfig.compression">
71          <option value="none">Brak</option>
72          <option value="gzip">GZIP</option>
73          <option value="brotli">Brotli</option>
74        </select>
75      </div>
76
77      <div class="setting">
78        <label>Limit rozmiaru chunka (KB):</label>
79        <input type="range" v-model.number="buildConfig.chunkSize" min="100" max="1000" step="50">
80        <span>{{ buildConfig.chunkSize }} KB</span>
81      </div>
82    </div>
83
84    <div class="stats-panel">
85      <h2>Statystyki buildu</h2>
86      <div class="stat">
87        <span>Rozmiar paczki:</span>
88        <strong :class="{ optimal: buildStats < 400 }">{{ buildStats }} KB</strong>
89      </div>
90      <div class="stat">
91        <span>Wynik optymalizacji:</span>
92        <strong :class="{ optimal: optimizationScore >= 80 }">{{ optimizationScore }}/100</strong>
93      </div>
94      <div class="status" :class="optimizationScore >= 80 ? 'ready' : 'warning'">
95        {{ optimizationScore >= 80 ? 'Gotowe do startu!' : 'Wymaga optymalizacji' }}
96      </div>
97    </div>
98  </div>
99</template>
100
101<style scoped>
102.launch-control {
103  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
104  color: #00ff88;
105  padding: 2rem;
106  min-height: 100vh;
107  font-family: 'Courier New', monospace;
108}
109
110.header {
111  text-align: center;
112  margin-bottom: 2rem;
113  border-bottom: 2px solid #00b4d8;
114  padding-bottom: 1rem;
115}
116
117.header h1 {
118  color: #00ff88;
119  text-shadow: 0 0 10px #00ff88;
120  margin: 0;
121}
122
123.header p {
124  color: #00b4d8;
125  margin: 0.5rem 0 0 0;
126}
127
128.config-panel, .stats-panel {
129  background: rgba(0, 180, 216, 0.1);
130  border: 1px solid #00b4d8;
131  border-radius: 8px;
132  padding: 1.5rem;
133  margin-bottom: 1.5rem;
134}
135
136.config-panel h2, .stats-panel h2 {
137  color: #00b4d8;
138  margin-top: 0;
139  font-size: 1.2rem;
140}
141
142.setting {
143  margin-bottom: 1rem;
144  padding: 0.75rem;
145  background: rgba(0, 255, 136, 0.05);
146  border-radius: 4px;
147}
148
149.setting label {
150  display: flex;
151  align-items: center;
152  gap: 0.5rem;
153  color: #00ff88;
154}
155
156.setting input[type="checkbox"] {
157  width: 20px;
158  height: 20px;
159}
160
161.setting select, .setting input[type="range"] {
162  margin-left: 0.5rem;
163  padding: 0.5rem;
164  background: #0a0e27;
165  border: 1px solid #00b4d8;
166  color: #00ff88;
167  border-radius: 4px;
168}
169
170.stat {
171  display: flex;
172  justify-content: space-between;
173  padding: 0.75rem;
174  margin-bottom: 0.5rem;
175  background: rgba(0, 255, 136, 0.05);
176  border-radius: 4px;
177}
178
179.stat strong {
180  color: #fff;
181  font-size: 1.2rem;
182}
183
184.stat strong.optimal {
185  color: #00ff88;
186  text-shadow: 0 0 5px #00ff88;
187}
188
189.status {
190  text-align: center;
191  padding: 1rem;
192  margin-top: 1rem;
193  border-radius: 4px;
194  font-size: 1.1rem;
195  font-weight: bold;
196}
197
198.status.ready {
199  background: rgba(0, 255, 136, 0.2);
200  color: #00ff88;
201  border: 2px solid #00ff88;
202}
203
204.status.warning {
205  background: rgba(255, 165, 0, 0.2);
206  color: #ffa500;
207  border: 2px solid #ffa500;
208}
209</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. Dlaczego testowanie jest kluczowe w aplikacjach Vue?

  2. 2. Dlaczego Vitest jest zalecanym frameworkiem testowym dla Vue + Vite?

To 2 z 5 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Edytor kodu

    Uzupełnij plik konfiguracyjny vitest.config.js, aby poprawnie skonfigurować środowisko testowe Vue.

  • Klikanie w kolejności

    Ułóż elementy struktury testu w poprawnej kolejności:

  • Edytor kodu

    Napisz test sprawdzający czy komponent Counter renderuje się poprawnie i reaguje na kliknięcie.

  • Układanie w pionie

    Uporządkuj kroki pisania testu komponentu Vue:

  • Układanie w poziomie

    Ułóż składnię montowania komponentu z props w teście:

  • Edytor kodu

    Napisz test sprawdzający czy komponent emituje event 'select' po kliknięciu i przekazuje dane artwork.

  • Układanie w pionie

    Ułóż typy testów od najszybszych i najtańszych do najwolniejszych i najdroższych:

  • Klikanie w kolejności

    Ułóż kod symulujący kliknięcie przycisku w teście:

Przydatne artykuły