<v/>
{ }
Vue 3 · Vitest · Testing · Composables
Vue Composable Testing
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.

13 Min. Lesezeit Vitest · @vue/test-utils · withSetup · vi.useFakeTimers Vue 3.x · Vitest 1.x · TypeScript

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.

11. FAQ: Vue Composable Testing

1Warum nicht direkt außerhalb setup() testen?
Lifecycle-Hooks brauchen eine aktive Instanz. withSetup-Wrapper erstellt diesen Kontext. Ohne ihn ignorieren Hooks sich still oder warnen.
2nextTick vs. flushPromises?
nextTick: ein Reaktivitäts-Flush. flushPromises: alle pending Promises. Für async fetch: flushPromises. Oft beide kombinieren.
3Cleanup-Test für Event-Listener?
vi.spyOn(window, 'removeEventListener'), withSetup + unmount() aufrufen, dann spy-Aufruf mit gleichen Argumenten verifizieren.
4Fake Timer testen?
vi.useFakeTimers() in beforeEach, vi.useRealTimers() in afterEach. advanceTimersByTime() für Zeitsprünge ohne echtes Warten.
5vi.stubGlobal fetch oder MSW?
vi.stubGlobal für einfache Szenarien. MSW für komplexes API-Mocking mit URL-Routing, Fehler und Netzwerk-Simulation.
6Frische Pinia pro Test?
Stores sind Singletons. Ohne frische Instanz überträgt State sich zwischen Tests. setActivePinia(createPinia()) in beforeEach.
7effectScope statt withSetup?
Ja, für Composables ohne Lifecycle-Hooks. Leichtgewichtiger als withSetup. Für reine Reaktivitätstests bevorzugt.
8Reaktive URL in useFetch testen?
URL-Ref erstellen, instanziieren, flushPromises(). Dann URL ändern, nextTick(), flushPromises(), Assertions prüfen.
9Verhalten statt Implementierung testen?
Verhaltenstests überleben Refactoring. Nicht: "wurde onMounted aufgerufen?" Sondern: "ist der Listener nach Mount aktiv?"
10Wie viel Coverage für Composables?
Kritische, oft genutzte Composables nahe 100%. Je mehr Komponenten teilen, desto wertvoller der Test. Einmalig verwendete Utilities können weniger haben.