Wiederverwendbare Logik isoliert prüfen
Composables sind der wichtigste Wiederverwendungsmechanismus in Vue 3 – aber wie testet man reaktive Logik, die Lifecycle-Hooks registriert, auf Timer angewiesen ist oder fetch-Aufrufe macht? Composable Testing ist kein Sonderfall des Komponenten-Testings, sondern ein eigener Ansatz mit eigenen Werkzeugen und Mustern.
Inhaltsverzeichnis
- 1. Warum Composable Testing ein eigener Ansatz ist
- 2. Vitest und @vue/test-utils einrichten
- 3. Der withSetup-Wrapper: Composables im reaktiven Kontext testen
- 4. Reaktivität testen: nextTick und flushPromises richtig nutzen
- 5. Lifecycle-Hooks simulieren: onMounted und onUnmounted im Test
- 6. Timer-Mocking: useDebounce und useInterval testen
- 7. Fetch-Mocking: useFetch-Composables ohne echte API testen
- 8. Pinia-Stores in Composable-Tests mocken
- 9. Testing-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Composable Testing ein eigener Ansatz ist
Vue Composables sind normale JavaScript-Funktionen – das macht sie prinzipiell testbar wie jede andere Funktion. Das Problem entsteht, wenn das Composable Vue-spezifische Features nutzt: onMounted und onUnmounted benötigen eine aktive Komponenteninstanz. inject() erwartet einen provide-Kontext im Komponentenbaum. watch und watchEffect stoppen automatisch, wenn die Komponenteninstanz unmounted wird. Ein Composable direkt außerhalb eines Komponentenkontexts aufzurufen führt zu Warnungen und unerwartetem Verhalten, weil Lifecycle-Hooks keine Instanz finden, an die sie sich hängen können.
Für Vue Composable Testing braucht man daher eine Testumgebung, die einen minimalen reaktiven Komponentenkontext bereitstellt. Die gängigste Lösung ist ein withSetup-Wrapper: Eine Hilfsfunktion, die eine minimale Vue-Komponente mit mountComponent oder mount aus @vue/test-utils erstellt, das Composable in deren setup()-Funktion aufruft und die Rückgabewerte nach außen reicht. Dieser Wrapper ist das Fundament jeder Composable-Testing-Strategie für Composables mit Lifecycle-Hooks.
Composables ohne Lifecycle-Hooks – reine Reaktivitätslogik mit ref, computed und watch – können unter bestimmten Bedingungen ohne Wrapper direkt in Tests aufgerufen werden. effectScope() aus Vue bietet einen reaktiven Kontext für watch und watchEffect, ohne eine vollständige Komponenteninstanz zu erstellen. Das ist leichtgewichtiger als der withSetup-Wrapper und für einfache Vue Composable Tests die empfohlene Wahl.
2. Vitest und @vue/test-utils einrichten
Vitest ist die natürliche Wahl für Vue Composable Testing, weil es denselben Vite-Build-Stack verwendet, der in modernen Vue-3-Projekten Standard ist. Die Konfiguration in vitest.config.ts setzt die environment auf jsdom oder happy-dom – Letzteres ist schneller und für die meisten Composable Testing-Szenarien ausreichend. @vue/test-utils ist die offizielle Vue-Testing-Bibliothek und stellt mount, shallowMount und flushPromises bereit.
Die Konfiguration für globale Test-Setups – setupFiles in Vitest – erlaubt, Pinia global zu initialisieren, globale Komponenten zu registrieren und Mocks einzurichten, die in allen Tests verfügbar sein sollen. Ein globaler beforeEach-Hook mit setActivePinia(createPinia()) stellt sicher, dass jeder Test eine frische Pinia-Instanz bekommt – essenziell für isoliertes Vue Composable Testing mit Store-abhängigen Composables. Das verhindert Test-Kontamination durch State aus vorherigen Tests.
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom', // faster than jsdom for composable tests
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
resolve: {
alias: { '@': resolve(__dirname, 'src') },
},
})
// src/test/setup.ts — global test setup
import { beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Fresh Pinia instance per test — prevents state contamination
beforeEach(() => {
setActivePinia(createPinia())
})
// src/test/withSetup.ts — utility for testing composables with lifecycle hooks
import { createApp } from 'vue'
export function withSetup<T>(composable: () => T): [T, () => void] {
let result!: T
const app = createApp({ setup() { result = composable(); return () => {} } })
app.mount(document.createElement('div'))
const unmount = () => app.unmount()
return [result, unmount]
}
3. Der withSetup-Wrapper: Composables im reaktiven Kontext testen
Der withSetup-Wrapper ist das universelle Werkzeug für Vue Composable Testing mit Lifecycle-Hooks. Er erstellt eine minimale Vue-Applikation, mountet sie auf ein frisches DOM-Element, ruft das Composable im setup()-Kontext auf und gibt die Rückgabewerte zusammen mit einer unmount-Funktion zurück. Die unmount-Funktion ist entscheidend: sie triggert onUnmounted-Hooks und erlaubt zu testen, ob das Composable sauber aufräumt.
Ein kompletter Composable-Test mit withSetup folgt dem Arrange-Act-Assert-Muster. Arrange: Das Composable mit Testdaten initialisieren. Act: Methoden aufrufen oder State verändern. Assert: Rückgabewerte mit reaktiven Assertions prüfen. Am Ende des Tests: unmount() aufrufen, um Cleanup zu triggern und Memory-Leaks im Test-Runner zu vermeiden. Dieser Lifecycle-Test – setzt das Composable Event-Listener korrekt in onMounted und entfernt sie in onUnmounted – ist für Browser-API-Composables besonders wichtig.
Alternativ zu withSetup bietet @vue/test-utils die Möglichkeit, das Composable direkt in einer Testkomponente zu mounten. Das ist ausführlicher, aber ermöglicht das Testen des Composables in Kombination mit echtem Template-Rendering – nützlich für Composables, die Template-Refs nutzen. Für reine Logik-Tests ohne Template-Interaktion ist withSetup die schlankere Wahl.
4. Reaktivität testen: nextTick und flushPromises richtig nutzen
Vue-Reaktivität ist asynchron: Wenn ein ref-Wert geändert wird, werden Watchers und computed-Properties nicht sofort neu berechnet, sondern in der nächsten Mikrotask-Queue. Das bedeutet: Direkt nach einer State-Änderung ist der neue Wert in ref.value sichtbar, aber abhängige computed-Properties und watch-Callbacks haben noch nicht reagiert. Vue Composable Testing muss diese Asynchronität durch await nextTick() explizit auflösen.
flushPromises() aus @vue/test-utils wartet auf alle pending Promises und Mikrotasks – nützlicher als nextTick() für Composables mit async Operationen wie Fetch-Aufrufen. Nach await flushPromises() sollten alle async Effekte abgeschlossen und reaktive Assertions korrekt sein. Das häufigste Testfehler bei Vue Composable Testing: fehlende await nextTick() oder await flushPromises() nach State-Änderungen, die zu falschen Assertions auf noch nicht aktualisierten Werten führen.
// src/composables/__tests__/usePagination.test.ts
import { describe, it, expect } from 'vitest'
import { nextTick } from 'vue'
import { withSetup } from '@/test/withSetup'
import { usePagination } from '../usePagination'
describe('usePagination', () => {
it('initializes with correct defaults', () => {
const [{ currentPage, totalPages }] = withSetup(() => usePagination(50, 10))
expect(currentPage.value).toBe(1)
expect(totalPages.value).toBe(5)
})
it('navigates to next page', async () => {
const [{ currentPage, nextPage }] = withSetup(() => usePagination(50, 10))
nextPage()
await nextTick() // wait for reactive updates
expect(currentPage.value).toBe(2)
})
it('does not exceed total pages', async () => {
const [{ currentPage, goToPage, totalPages }] = withSetup(() => usePagination(30, 10))
goToPage(99)
await nextTick()
expect(currentPage.value).toBe(1) // unchanged — guard kicked in
expect(totalPages.value).toBe(3)
})
it('resets page correctly', async () => {
const [{ currentPage, nextPage, resetPage }] = withSetup(() => usePagination(50))
nextPage(); nextPage()
await nextTick()
expect(currentPage.value).toBe(3)
resetPage()
await nextTick()
expect(currentPage.value).toBe(1)
})
})
5. Lifecycle-Hooks simulieren: onMounted und onUnmounted im Test
Das Testen von Composables, die onMounted und onUnmounted nutzen, erfordert den withSetup-Wrapper und explizites unmount(). Ein typisches Testszenario: Ein useEventListener-Composable, das in onMounted einen Event-Listener registriert und in onUnmounted entfernt. Der Test prüft, ob der Listener nach dem Mount registriert ist, und ob er nach dem Unmount nicht mehr ausgeführt wird.
Spies mit vi.spyOn(window, 'addEventListener') und vi.spyOn(window, 'removeEventListener') erlauben, die genaue Anzahl und die Argumente der DOM-API-Aufrufe zu verifizieren, ohne echte Events zu triggern. Das ist robuster als zu testen, ob eine Callback-Funktion aufgerufen wurde, weil es die interne Implementierung des Composables direkt prüft. Nach dem Unmount sollte removeEventListener mit denselben Argumenten wie addEventListener aufgerufen worden sein – der Test verifiziert vollständiges Cleanup.
Ein fortgeschrittenes Composable-Testing-Muster für onMounted: Tests, die prüfen, ob asynchrone Initialisierung in onMounted korrekt abläuft. Da onMounted synchron nach dem Mount ausgeführt wird, aber async Operationen innerhalb onMounted nicht abgewartet werden, muss der Test nach dem Mount await flushPromises() aufrufen, um auf das Ende der async Operationen zu warten. Das ist ein häufiger Fallstrick beim Testen von Vue Composables.
6. Timer-Mocking: useDebounce und useInterval testen
Composables, die setTimeout, setInterval oder requestAnimationFrame verwenden, können in Tests nicht mit echten Timern getestet werden, ohne die Tests auf Sekunden zu verlangsamen. Vitest bietet vi.useFakeTimers(), das alle Browser-Timer durch synchron steuerbare Fake-Timer ersetzt. Mit vi.advanceTimersByTime(300) springt man in der Test-Zeitlinie um 300 Millisekunden vor, ohne wirklich zu warten. Das macht das Vue Composable Testing von Debounce-, Throttle- und Interval-Composables schnell und deterministisch.
Das Cleanup nach Timer-Tests ist wichtig: vi.useRealTimers() im afterEach-Hook stellt sicher, dass andere Tests nicht von den Fake-Timern beeinflusst werden. Alternativ konfiguriert man Fake-Timer im beforeEach und Restore im afterEach. Vue Composable Tests für Interval-Composables prüfen typischerweise, ob der Interval nach dem Unmount gestoppt wird – indem man nach dem Unmount die Zeit voranschreitet und sicherstellt, dass der Callback nicht mehr aufgerufen wird.
// src/composables/__tests__/useDebounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
import { withSetup } from '@/test/withSetup'
import { useDebounce } from '../useDebounce'
describe('useDebounce', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('delays value update by specified ms', async () => {
const source = ref('initial')
const [{ debouncedValue }] = withSetup(() => useDebounce(source, 300))
expect(debouncedValue.value).toBe('initial')
source.value = 'updated'
// No time passed yet — debounce not fired
expect(debouncedValue.value).toBe('initial')
vi.advanceTimersByTime(300) // jump forward in time without waiting
await Promise.resolve() // flush microtasks for reactivity
expect(debouncedValue.value).toBe('updated')
})
it('cancels previous timer on rapid input', async () => {
const source = ref('a')
const [{ debouncedValue }] = withSetup(() => useDebounce(source, 300))
source.value = 'b'
vi.advanceTimersByTime(100)
source.value = 'c' // restart timer
vi.advanceTimersByTime(200)
expect(debouncedValue.value).toBe('a') // still debouncing
vi.advanceTimersByTime(100)
await Promise.resolve()
expect(debouncedValue.value).toBe('c') // final value after full delay
})
})
7. Fetch-Mocking: useFetch-Composables ohne echte API testen
Composables, die fetch aufrufen, müssen in Tests gegen gemockte Responses getestet werden. Vitest bietet vi.stubGlobal('fetch', vi.fn()), um fetch durch eine Mock-Funktion zu ersetzen. Die Mock-Funktion gibt ein Promise zurück, das eine Response-ähnliche Struktur mit ok, status und json()-Methode simuliert. Für komplexere Szenarien – verschiedene Responses für verschiedene URLs, Netzwerkfehler, Timeouts – ist die Bibliothek msw (Mock Service Worker) die robustere Wahl.
Composable Testing für Fetch-Composables folgt dem Muster: Fetch mocken, Composable instanziieren, await flushPromises() warten, Assertions auf data, loading und error machen. Tests für den Fehlerfall sind mindestens genauso wichtig wie Tests für den Erfolgsfall: Was passiert, wenn response.ok false ist? Was passiert bei einem Netzwerkfehler? Das Composable sollte in beiden Fällen den error-Ref korrekt setzen und loading auf false zurücksetzen. Tests, die diese Szenarien abdecken, sind der wertvollste Teil der Vue Composable Testing-Suite.
Ein wichtiger Aspekt beim Testen von useFetch-Composables: Das Testen reaktiver URLs. Wenn das Composable bei URL-Änderung automatisch neu fetcht, muss der Test die URL-Ref ändern, await nextTick() für den Watch-Trigger und dann await flushPromises() für den neuen Fetch warten. Tests mit reaktiven Parametern verifizieren die gesamte Watch-Fetch-State-Kette des Composables.
8. Pinia-Stores in Composable-Tests mocken
Composables, die intern Pinia-Stores aufrufen, können auf zwei Arten getestet werden: mit echten Store-Instanzen oder mit gemockten Stores. Echte Stores in Tests zu verwenden ist oft die einfachere Wahl – vorausgesetzt, jeder Test bekommt eine frische Pinia-Instanz via setActivePinia(createPinia()). Der Store-State kann dann direkt manipuliert werden: authStore.user = testUser, ohne Mock-Boilerplate. Für Vue Composable Testing mit einfachen Store-Abhängigkeiten ist dieser Ansatz vorzuziehen.
Für Composables, die von Stores mit komplexen Actions oder externen Abhängigkeiten abhängen, ist Store-Mocking sinnvoller. Vitest ermöglicht das Mocken eines Store-Moduls mit vi.mock('@/stores/useAuthStore') und einer Mock-Implementierung, die die Store-Interface implementiert. Das isoliert das Composable vollständig von seinen Store-Abhängigkeiten und macht Tests schneller und deterministischer. Der Nachteil: Mock-Implementierungen können von der echten Store-Schnittstelle abweichen, wenn der Store geändert wird – TypeScript hilft hier, indem es Typ-Abweichungen sofort meldet.
| Composable-Typ | Testing-Ansatz | Werkzeug | Async-Handling |
|---|---|---|---|
| Reine Reaktivität | effectScope direkt | Vue effectScope | nextTick |
| Mit Lifecycle-Hooks | withSetup-Wrapper | createApp + mount | nextTick + unmount() |
| Mit Timer/Interval | Fake Timer | vi.useFakeTimers() | advanceTimersByTime |
| Mit fetch/API | Fetch Mock / MSW | vi.stubGlobal / msw | flushPromises |
| Mit Pinia Store | Echter Store oder Mock | createPinia / vi.mock | nextTick + flushPromises |
9. Testing-Strategien im Vergleich
Die Strategie für Vue Composable Testing hängt stark vom Typ des Composables ab. Einfache Berechnungslogik ohne Lifecycle-Hooks und ohne externe Abhängigkeiten kann mit einer einfachen Testfunktion ohne Wrapper getestet werden. Das ist die schnellste und direkteste Form des Composable Testings und sollte bevorzugt werden, wann immer möglich. Je komplexer die externe Abhängigkeiten, desto mehr Setup braucht der Test – aber desto wertvoller ist der Test auch, weil er die Integration verschiedener Teile des Composables verifiziert.
Der wichtigste Grundsatz beim Vue Composable Testing: Teste das Verhalten, nicht die Implementierung. Ein Test, der prüft, ob onMounted aufgerufen wurde, ist ein Implementierungstest. Ein Test, der prüft, ob der Event-Listener nach dem Mount aktiv ist, ist ein Verhaltenstest. Verhaltenstests sind robuster gegenüber Refactoring: Wenn das Composable das Lifecycle-Timing intern ändert, muss der Verhaltenstest nicht angepasst werden.
Mironsoft
Vue-3-Testing, Vitest-Setup und Composable-Testarchitektur
Composable-Testarchitektur für euer Vue-3-Projekt?
Wir richten Vitest, @vue/test-utils und MSW für euer Projekt ein und entwickeln eine Testing-Strategie für alle Composable-Typen – von einfacher Reaktivität bis zu komplexen Store-abhängigen Workflows.
Test-Setup
Vitest, happy-dom, MSW und Pinia-Test-Infrastruktur einrichten und konfigurieren
Test-Entwicklung
Bestehende Composables nachträglich testen und withSetup-Muster in der Codebasis etablieren
CI-Integration
Vitest in CI/CD-Pipelines integrieren mit Coverage-Reports und Branch-Protection
10. Zusammenfassung
Vue Composable Testing ist keine Erweiterung des Komponenten-Testings, sondern ein eigener Ansatz, der die Besonderheiten von Composables berücksichtigt: reaktiver Kontext, Lifecycle-Hooks, Timer, Fetch-Aufrufe und Store-Abhängigkeiten. Der withSetup-Wrapper ist das universelle Werkzeug für Composables mit Lifecycle-Hooks. vi.useFakeTimers() macht Timer-basierte Composables deterministisch testbar. Fetch-Mocking mit vi.stubGlobal oder MSW ermöglicht isolierte API-Tests. Frische Pinia-Instanzen per Test verhindern State-Kontamination.
Die Investition in Composable Tests zahlt sich proportional zur Wiederverwendung des Composables aus. Ein Composable, das in zwanzig Komponenten genutzt wird, und dessen Kernlogik durch einen Bug korrumpiert wird, bricht zwanzig Features gleichzeitig. Drei Tests für dieses Composable – Normalfall, Fehlerfall, Edge-Case – schützen alle zwanzig Komponenten gleichzeitig. Das ist der Kernvorteil von Composable Testing gegenüber Komponenten-Integrationstests.
Vue Composable Testing — Das Wichtigste auf einen Blick
withSetup-Wrapper
Minimale Vue-App für reaktiven Kontext. Pflicht bei Composables mit Lifecycle-Hooks. unmount() am Testende aufrufen.
Async-Handling
nextTick() nach State-Änderungen. flushPromises() nach async Operationen. Beide oft in Kombination nötig.
Timer & Fetch
vi.useFakeTimers() für deterministisches Timer-Testing. vi.stubGlobal('fetch') oder MSW für API-Mocking.
Pinia in Tests
setActivePinia(createPinia()) pro Test in beforeEach. Echte Stores für einfache Abhängigkeiten, vi.mock für komplexe.