Kurs Vue.js · Moduł 12: Testy i wdrożenie
Testing Vue Components
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-domFlaga -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. Dlaczego testowanie jest kluczowe w aplikacjach Vue?
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: