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

Testing Integration & E2E

6 min czytania
W tej lekcji5

Testy integracyjne sprawdzają współpracę systemów - jak systemy rakiety współpracują przed startem na Marsa.

Każdy podzespół panelu przeszedł już próby w izolacji: licznik, composable, store archiwum. To jednak nie gwarantuje, że zadziałają razem. Komponent może czytać ze store'u pole o innej nazwie, niż store wystawia, a router może nie wyrenderować widoku, na który prowadzi link. Takich usterek nie złapie żaden test jednostkowy, bo pojawiają się dopiero na styku. Dlatego teraz składamy podzespoły z powrotem w całość.

Integracja a testy jednostkowe

Test jednostkowy sprawdza pojedynczą jednostkę w izolacji, a test integracyjny - współpracę wielu części naraz: komponentu ze store'em, komponentu z routerem, rodzica z dziećmi. Tu przydaje się różnica między dwiema funkcjami Vue Test Utils. mount() renderuje pełne drzewo komponentów razem z dziećmi, a shallowMount() zastępuje dzieci zaślepkami (stubami), które w HTML wyglądają jak <child-stub>. W teście integracyjnym używasz mount(), bo dzieci mają naprawdę działać.

Component Integration Tests

Pierwszy test sprawdza, czy galeria wyświetla dane ze store'u Pinia. Opcja global.plugins instaluje wtyczki w testowej aplikacji tak, jak robi to app.use() w main.js:

1// ArtworkGallery.spec.js
2import { describe, it, expect, beforeEach } from 'vitest'
3import { mount } from '@vue/test-utils'
4import { createPinia, setActivePinia } from 'pinia'
5import ArtworkGallery from './ArtworkGallery.vue'
6import { useGalleryStore } from '@/stores/gallery'
7
8describe('ArtworkGallery Integration', () => {
9  beforeEach(() => {
10    setActivePinia(createPinia())
11  })
12
13  it('displays artworks from store', async () => {
14    const wrapper = mount(ArtworkGallery, {
15      global: {
16        plugins: [createPinia()]
17      }
18    })
19
20    const store = useGalleryStore()
21
22    store.artworks = [
23      { id: 1, title: 'Mona Lisa', artist: 'Leonardo' },
24      { id: 2, title: 'David', artist: 'Michelangelo' }
25    ]
26
27    await wrapper.vm.$nextTick()
28
29    const cards = wrapper.findAll('.artwork-card')
30    expect(cards).toHaveLength(2)
31  })

Instalacja Pinii przez global.plugins ustawia tę instancję jako aktywną, więc useGalleryStore() w teście i w komponencie trafia do tego samego magazynu. Po zmianie danych czekamy na $nextTick(), bo Vue przerysowuje DOM asynchronicznie. findAll zwraca listę pasujących elementów, a toHaveLength(2) sprawdza ich liczbę. Drugi przypadek dotyka trzech warstw naraz: pola wyszukiwania, gettera filtrującego w store'ze i listy kart. Metoda setValue wpisuje tekst do pola i wysyła zdarzenie input, tak jak zrobiłby to technik:

1  // ArtworkGallery.spec.js - dalszy ciąg describe
2  it('filters artworks on search input', async () => {
3    const wrapper = mount(ArtworkGallery, {
4      global: {
5        plugins: [createPinia()]
6      }
7    })
8
9    const store = useGalleryStore()
10    store.artworks = [
11      { id: 1, title: 'Mona Lisa', artist: 'Leonardo' },
12      { id: 2, title: 'David', artist: 'Michelangelo' }
13    ]
14
15    await wrapper.vm.$nextTick()
16
17    const searchInput = wrapper.find('input[type="search"]')
18    await searchInput.setValue('mona')
19
20    await wrapper.vm.$nextTick()
21
22    const cards = wrapper.findAll('.artwork-card')
23    expect(cards).toHaveLength(1)
24    expect(cards[0].text()).toContain('Mona Lisa')
25  })
26})

Test nie wywołuje gettera bezpośrednio, tylko przechodzi przez interfejs, więc sprawdza także, czy szablon jest naprawdę podpięty do store'u. Sam store nie został niczym zastąpiony - to ten sam kod, który poleci na Marsa.

Router Integration

Komponent z <RouterView> potrzebuje działającego routera. W teście tworzysz go funkcją createRouter z historią createMemoryHistory, która trzyma adres w pamięci zamiast w pasku przeglądarki, a potem montujesz aplikację z routerem w global.plugins:

1import { mount } from '@vue/test-utils'
2import { createRouter, createMemoryHistory } from 'vue-router'
3import App from './App.vue'
4
5describe('App with Router', () => {
6  it('navigates to gallery page', async () => {
7    const router = createRouter({
8      history: createMemoryHistory(),
9      routes: [
10        { path: '/', component: { template: '<div>Home</div>' } },
11        { path: '/gallery', component: { template: '<div>Gallery</div>' } }
12      ]
13    })
14
15    const wrapper = mount(App, {
16      global: {
17        plugins: [router]
18      }
19    })
20
21    await router.push('/gallery')
22    await router.isReady()
23
24    expect(wrapper.html()).toContain('Gallery')
25  })
26})

router.push zwraca obietnicę nawigacji, a router.isReady() czeka na zakończenie pierwszej nawigacji, dlatego oba wywołania mają await. Zastępcze widoki zapisane jako obiekt z polem template wystarczają w teście; w aplikacji budowanej przez Vite użyjesz plików .vue, bo domyślna przeglądarkowa wersja Vue nie kompiluje szablonów w locie.

E2E Tests (Cypress/Playwright)

Testy E2E działają piętro wyżej: uruchamiają prawdziwą przeglądarkę, otwierają aplikację pod adresem i klikają jak człowiek. Nie montujesz tu komponentów - odwiedzasz stronę. Dokumentacja Vue poleca do tego Playwright oraz Cypress, a kreator create-vue proponuje Playwright już przy tworzeniu projektu. Tak wygląda scenariusz w Cypress:

1// cypress/e2e/gallery.cy.js
2describe('Gallery E2E', () => {
3  beforeEach(() => {
4    cy.visit('/')
5  })
6
7  it('displays artworks', () => {
8    cy.get('.artwork-card').should('have.length.greaterThan', 0)
9  })
10
11  it('filters artworks by search', () => {
12    cy.get('input[type="search"]').type('mona')
13
14    cy.get('.artwork-card').should('have.length', 1)
15    cy.get('.artwork-card').first().should('contain', 'Mona Lisa')
16  })

cy.visit('/') otwiera stronę względem adresu bazowego (baseUrl) z konfiguracji Cypress. Asercje should ponawiają się same, aż warunek się spełni albo minie limit czasu, więc nie czekasz ręcznie na render. Kolejne przypadki idą dalej ścieżką operatora - do szczegółów obrazu i ulubionych:

1  // cypress/e2e/gallery.cy.js - dalszy ciąg describe
2  it('opens artwork detail on click', () => {
3    cy.get('.artwork-card').first().click()
4
5    cy.url().should('include', '/artwork/')
6    cy.get('.artwork-detail').should('be.visible')
7  })
8
9  it('adds artwork to favorites', () => {
10    cy.get('.artwork-card').first().find('.favorite-btn').click()
11
12    cy.get('.favorites-count').should('contain', '1')
13  })
14})

Test nie zna żadnego komponentu, store'u ani trasy - widzi tylko to co użytkownik: adres, elementy i ich tekst.

Playwright Example

Ten sam scenariusz w Playwright. Każdy test dostaje obiekt page, czyli kartę przeglądarki, a kroki są zawsze te same: otwórz aplikację (page.goto), znajdź element (page.locator), wykonaj akcję (click, fill) i sprawdź wynik (expect):

1// tests/gallery.spec.js
2import { test, expect } from '@playwright/test'
3
4test.describe('Gallery', () => {
5  test('should display artworks', async ({ page }) => {
6    await page.goto('/')
7
8    const cards = page.locator('.artwork-card')
9    await expect(cards).toHaveCount(12)
10  })
11
12  test('should filter artworks', async ({ page }) => {
13    await page.goto('/')
14
15    await page.locator('input[type="search"]').fill('leonardo')
16
17    const cards = page.locator('.artwork-card')
18    await expect(cards).toHaveCount(2)
19  })

Metody page.fill() i page.click() z selektorem w argumencie wciąż istnieją, ale dokumentacja Playwright oznacza je jako odradzane i zaleca akcje na lokatorach, takie jak locator.fill(). Asercje w rodzaju toHaveCount same czekają na spełnienie warunku. Trzeci test przechodzi do szczegółów: first() wybiera pierwszy pasujący element, a toHaveURL sprawdza adres wyrażeniem regularnym:

1  // tests/gallery.spec.js - dalszy ciąg test.describe
2  test('should navigate to artwork detail', async ({ page }) => {
3    await page.goto('/')
4
5    await page.locator('.artwork-card').first().click()
6
7    await expect(page).toHaveURL(/.*artwork\/\d+/)
8    await expect(page.locator('.artwork-detail')).toBeVisible()
9  })
10})

Asercja toBeVisible potwierdza, że panel szczegółów faktycznie widać. Najkrótszy test dymny sprawdza tytuł karty przeglądarki asercją toHaveTitle:

1test('shows the gallery title', async ({ page }) => {
2  await page.goto('/')
3  await expect(page).toHaveTitle('Gallery')
4})

Tytuł pochodzi z elementu <title> w index.html, więc gdy ten test nie przejdzie, aplikacja najpewniej w ogóle nie wystartowała. Wszystkie testy wołają page.goto('/') ze ścieżką względną, co działa tylko z adresem bazowym w playwright.config.js:

1// playwright.config.js
2import { defineConfig } from '@playwright/test'
3
4export default defineConfig({
5  use: {
6    baseURL: 'http://localhost:4173'
7  },
8  webServer: {
9    command: 'npm run build && npm run preview',
10    url: 'http://localhost:4173'
11  }
12})

Sekcja webServer każe Playwright samemu zbudować aplikację i podnieść vite preview, którego domyślnym portem jest 4173 - testujesz więc dokładnie ten ładunek, który poleci na produkcję.

Moja rada: trzymaj proporcje piramidy - dziesiątki testów jednostkowych, kilka integracyjnych dla kluczowych połączeń i garść testów E2E dla ścieżek, bez których misja traci sens. Raport pokrycia z Vitest podpowie, których miejsc kodu nie odwiedził jeszcze żaden test. Gdy wszystko świeci na zielono, zostaje zbudować ładunek do wysyłki - tym zajmiemy się w następnej lekcji.

Zapamiętaj: testy jednostkowe sprawdzają zawory, integracyjne całe instalacje, a E2E to lot próbny całej rakiety.

Kod do tej lekcji: App.vue
1<script setup>
2import { ref, computed } from 'vue'
3
4// NOVA LAB - lista kontrolna buildu produkcyjnego
5const checklist = ref([
6  { id: 1, task: 'Usun wywolania console.log', completed: false, critical: true },
7  { id: 2, task: 'Wlacz tryb produkcyjny', completed: false, critical: true },
8  { id: 3, task: 'Zoptymalizuj obrazy i zasoby', completed: false, critical: false },
9  { id: 4, task: 'Wlacz HTTPS/SSL', completed: false, critical: true },
10  { id: 5, task: 'Skonfiguruj sledzenie bledow', completed: false, critical: false },
11  { id: 6, task: 'Skonfiguruj CDN', completed: false, critical: false },
12  { id: 7, task: 'Wlacz kompresje (gzip/brotli)', completed: false, critical: true },
13  { id: 8, task: 'Przetestuj na wielu urzadzeniach', completed: false, critical: false },
14  { id: 9, task: 'Skonfiguruj monitoring', completed: false, critical: false },
15  { id: 10, task: 'Skonfiguruj naglowki cache', completed: false, critical: true },
16  { id: 11, task: 'Sprawdz naglowki bezpieczenstwa', completed: false, critical: true },
17  { id: 12, task: 'Zrob kopie zapasowa danych produkcyjnych', completed: false, critical: true }
18])
19
20const completedCount = computed(() => {
21  return checklist.value.filter(item => item.completed).length
22})
23
24const criticalCompleted = computed(() => {
25  const critical = checklist.value.filter(item => item.critical)
26  const completed = critical.filter(item => item.completed)
27  return completed.length
28})
29
30const criticalTotal = computed(() => {
31  return checklist.value.filter(item => item.critical).length
32})
33
34const progress = computed(() => {
35  return Math.round((completedCount.value / checklist.value.length) * 100)
36})
37
38const isReadyForLaunch = computed(() => {
39  return criticalCompleted.value === criticalTotal.value
40})
41
42function toggleTask(id) {
43  const task = checklist.value.find(item => item.id === id)
44  if (task) {
45    task.completed = !task.completed
46  }
47}
48
49function resetChecklist() {
50  checklist.value.forEach(item => {
51    item.completed = false
52  })
53}
54</script>
55
56<template>
57  <div class="checklist-app">
58    <div class="header">
59      <h1>Lista kontrolna przed startem</h1>
60      <p>Wdrozenie produkcyjne NOVA LAB</p>
61    </div>
62
63    <div class="progress-panel">
64      <div class="progress-bar">
65        <div class="progress-fill" :style="{ width: progress + '%' }"></div>
66      </div>
67      <div class="progress-stats">
68        <span>Razem: {{ completedCount }}/{{ checklist.length }}</span>
69        <span>Krytyczne: {{ criticalCompleted }}/{{ criticalTotal }}</span>
70        <span>{{ progress }}%</span>
71      </div>
72    </div>
73
74    <div class="launch-status" :class="isReadyForLaunch ? 'ready' : 'not-ready'">
75      <template v-if="isReadyForLaunch">
76        WSZYSTKIE SYSTEMY GOTOWE! Mozna wdrazac!
77      </template>
78      <template v-else>
79        Pozostale zadania krytyczne: {{ criticalTotal - criticalCompleted }}
80      </template>
81    </div>
82
83    <div class="checklist">
84      <div
85        v-for="item in checklist"
86        :key="item.id"
87        class="checklist-item"
88        :class="{
89          completed: item.completed,
90          critical: item.critical && !item.completed
91        }"
92        @click="toggleTask(item.id)"
93      >
94        <div class="checkbox">
95          <span v-if="item.completed">x</span>
96        </div>
97        <div class="task-content">
98          <span class="task-text">{{ item.task }}</span>
99          <span v-if="item.critical" class="badge">KRYTYCZNE</span>
100        </div>
101      </div>
102    </div>
103
104    <div class="actions">
105      <button @click="resetChecklist" class="reset-btn">
106        Resetuj liste
107      </button>
108    </div>
109  </div>
110</template>
111
112<style scoped>
113.checklist-app {
114  background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
115  color: #00ff88;
116  padding: 2rem;
117  min-height: 100vh;
118  font-family: 'Courier New', monospace;
119}
120
121.header {
122  text-align: center;
123  margin-bottom: 2rem;
124  border-bottom: 2px solid #00b4d8;
125  padding-bottom: 1rem;
126}
127
128.header h1 {
129  color: #00ff88;
130  text-shadow: 0 0 10px #00ff88;
131  margin: 0;
132}
133
134.header p {
135  color: #00b4d8;
136  margin: 0.5rem 0 0 0;
137}
138
139.progress-panel {
140  background: rgba(0, 180, 216, 0.1);
141  border: 1px solid #00b4d8;
142  border-radius: 8px;
143  padding: 1.5rem;
144  margin-bottom: 1.5rem;
145}
146
147.progress-bar {
148  height: 30px;
149  background: rgba(0, 255, 136, 0.1);
150  border-radius: 15px;
151  overflow: hidden;
152  margin-bottom: 1rem;
153  border: 1px solid #00b4d8;
154}
155
156.progress-fill {
157  height: 100%;
158  background: linear-gradient(90deg, #00b4d8, #00ff88);
159  transition: width 0.5s ease;
160  box-shadow: 0 0 10px #00ff88;
161}
162
163.progress-stats {
164  display: flex;
165  justify-content: space-between;
166  color: #00b4d8;
167  font-size: 0.9rem;
168}
169
170.launch-status {
171  text-align: center;
172  padding: 1.5rem;
173  margin-bottom: 1.5rem;
174  border-radius: 8px;
175  font-size: 1.2rem;
176  font-weight: bold;
177}
178
179.launch-status.ready {
180  background: rgba(0, 255, 136, 0.2);
181  color: #00ff88;
182  border: 2px solid #00ff88;
183  box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
184}
185
186.launch-status.not-ready {
187  background: rgba(255, 165, 0, 0.2);
188  color: #ffa500;
189  border: 2px solid #ffa500;
190}
191
192.checklist {
193  display: flex;
194  flex-direction: column;
195  gap: 0.75rem;
196  margin-bottom: 2rem;
197}
198
199.checklist-item {
200  display: flex;
201  align-items: center;
202  gap: 1rem;
203  padding: 1rem;
204  background: rgba(0, 180, 216, 0.1);
205  border: 1px solid #00b4d8;
206  border-radius: 8px;
207  cursor: pointer;
208  transition: all 0.3s;
209}
210
211.checklist-item:hover {
212  background: rgba(0, 180, 216, 0.2);
213  transform: translateX(5px);
214}
215
216.checklist-item.completed {
217  background: rgba(0, 255, 136, 0.1);
218  border-color: #00ff88;
219}
220
221.checklist-item.critical {
222  border-color: #ffa500;
223  border-width: 2px;
224}
225
226.checkbox {
227  width: 24px;
228  height: 24px;
229  border: 2px solid #00b4d8;
230  border-radius: 4px;
231  display: flex;
232  align-items: center;
233  justify-content: center;
234  font-size: 1.2rem;
235  flex-shrink: 0;
236}
237
238.completed .checkbox {
239  background: #00ff88;
240  border-color: #00ff88;
241  color: #0a0e27;
242}
243
244.task-content {
245  flex: 1;
246  display: flex;
247  justify-content: space-between;
248  align-items: center;
249}
250
251.task-text {
252  color: #fff;
253}
254
255.completed .task-text {
256  text-decoration: line-through;
257  opacity: 0.7;
258}
259
260.badge {
261  background: #ffa500;
262  color: #0a0e27;
263  padding: 0.25rem 0.75rem;
264  border-radius: 12px;
265  font-size: 0.75rem;
266  font-weight: bold;
267}
268
269.actions {
270  text-align: center;
271}
272
273.reset-btn {
274  padding: 1rem 2rem;
275  background: rgba(0, 180, 216, 0.2);
276  border: 2px solid #00b4d8;
277  color: #00b4d8;
278  font-size: 1rem;
279  font-family: 'Courier New', monospace;
280  border-radius: 8px;
281  cursor: pointer;
282  transition: all 0.3s;
283}
284
285.reset-btn:hover {
286  background: #00b4d8;
287  color: #0a0e27;
288  box-shadow: 0 0 20px #00b4d8;
289}
290</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. Czym różnią się testy integracyjne od unit testów?

  2. 2. Jaka jest różnica między mount() a shallowMount() w Vue Test Utils?

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

Zadania praktyczne w grze

  • Edytor kodu

    Napisz test integracyjny komponentu z Pinia store - sprawdź czy komponent wyświetla dane ze store.

  • Układanie w pionie

    Uporządkuj kroki testu end-to-end:

  • Klikanie w kolejności

    Ułóż kod Playwright otwierający stronę i sprawdzający tytuł:

  • Edytor kodu

    Napisz test Playwright sprawdzający filtrowanie galerii - wpisanie tekstu w search i weryfikację wyników.

  • Układanie w poziomie

    Ułóż kod montowania komponentu z routerem w teście:

Przydatne artykuły