Composables mocken in Vue-Tests: Strategien und Fallstricke
AI generated
<v/>
{ }
Vue.js · Composables · Vitest · Testing
Composables mocken in Vue-Tests
saubere Isolation ohne versteckten geteilten State

Composables mocken ist in Vue-Tests komplizierter als es zunächst wirkt, weil Composables oft globalen Zustand, Netzwerkzugriffe und reaktive Nebenwirkungen kombinieren. Mit vi.mock, gezielter Dependency Injection und provide/inject als Testing-Seam lassen sich Komponenten trotzdem zuverlässig isoliert testen, ohne echte Composable-Implementierungen mitzuschleppen.

17 Min. Lesezeit vi.mock · provide/inject · vi.spyOn Vitest · Vue 3 · Nuxt 3

1. Warum Composables besondere Mocking-Herausforderungen bringen

Ein Composable in Vue kapselt wiederverwendbare, reaktive Logik, oft mit eigenem internen Zustand, Netzwerkaufrufen und Nebenwirkungen wie Event-Listenern oder Timern. Genau diese Kapselung, die Composables in der Anwendungsentwicklung so wertvoll macht, erschwert das Composables mocken in Tests. Anders als eine reine Utility-Funktion mit einem Eingabewert und einem Rückgabewert hält ein Composable häufig Referenzen auf globale Singletons, etwa einen HTTP-Client oder einen globalen Event-Bus, was das isolierte Testen einer Komponente, die dieses Composable nutzt, erschwert.

Ein zweites Problem ist die enge Kopplung zwischen Composable und Komponente über direkte Imports. Wenn eine Komponente import { useAuth } from '@/composables/useAuth' schreibt und die Funktion direkt im setup()-Block aufruft, gibt es ohne zusätzliche Werkzeuge keinen offensichtlichen Ansatzpunkt, um im Test eine gemockte Version einzuschleusen. Genau hier setzen die Techniken in diesem Artikel an, angefangen bei Modul-Mocking mit vi.mock, über Dependency Injection bis zu provide/inject als expliziter Testing-Seam.

Composables mocken bedeutet also nicht nur, eine Funktion durch eine Attrappe zu ersetzen, sondern bewusst zu entscheiden, an welcher Grenze im Code diese Ersetzung stattfindet, und wie diese Grenze über viele Tests hinweg konsistent bleibt, ohne dass Tests sich gegenseitig durch geteilten Zustand beeinflussen.

2. Grundstruktur: vi.mock für Composable-Module

Der direkteste Weg, ein Composable zu mocken, ist vi.mock() auf Modulebene. Vitest ersetzt dabei das komplette Modul durch eine Attrappe, bevor die zu testende Komponente es importiert. Das funktioniert zuverlässig, solange das Composable als benannter Export aus einer eigenen Datei kommt und die Komponente es über einen relativen oder Alias-Import einbindet.

Wichtig ist, vi.mock() immer auf oberster Ebene der Testdatei zu platzieren, da Vitest diese Aufrufe per Hoisting vor alle Imports verschiebt. Wer versucht, vi.mock() bedingt oder innerhalb einer Testfunktion aufzurufen, erlebt unvorhersehbares Verhalten, weil das Hoisting die eigentliche Reihenfolge im Code ignoriert.


// UserProfile.test.js — mocking a composable module with vi.mock
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import UserProfile from "./UserProfile.vue";

// Hoisted to the top of the file automatically by Vitest
vi.mock("@/composables/useAuth", () => ({
  useAuth: () => ({
    user: { value: { id: 1, name: "Alice", role: "admin" } },
    isLoggedIn: { value: true },
    logout: vi.fn(),
  }),
}));

describe("UserProfile", () => {
  it("renders the user name from the mocked composable", () => {
    const wrapper = mount(UserProfile);
    expect(wrapper.text()).toContain("Alice");
  });

  it("shows the admin badge when the mocked role is admin", () => {
    const wrapper = mount(UserProfile);
    expect(wrapper.find("[data-testid='admin-badge']").exists()).toBe(true);
  });
});

Der Nachteil dieses Ansatzes: Jede Testdatei, die dasselbe Composable in einer anderen Konfiguration braucht, etwa einen ausgeloggten Nutzer, muss das Mock innerhalb der Datei neu strukturieren oder mit vi.mocked() zur Laufzeit anpassen. Für einfache, seltene Composable-Aufrufe ist das akzeptabel, für breit genutzte Composables wie useAuth lohnt sich eine zentrale Factory-Funktion für das Mock, die in mehreren Testdateien wiederverwendet wird.

3. Dependency Injection statt globaler Imports

Eine robustere Alternative zum Modul-Mocking ist, Composables von vornherein über Parameter statt über direkte Imports in eine Komponente zu reichen. Diese Form der Dependency Injection macht das Composables mocken trivial, weil beim Testen einfach eine andere Implementierung als Prop oder Funktionsparameter übergeben wird, ohne Modul-Mocking überhaupt zu benötigen.

In der Praxis bedeutet das, dass eine Komponente nicht direkt useOrders() importiert und aufruft, sondern eine Factory-Funktion als optionalen Parameter akzeptiert, die im Produktivbetrieb auf das echte Composable zeigt. Dieser Ansatz erhöht zwar die Signatur-Komplexität leicht, reduziert aber die Notwendigkeit für Mocking-Frameworks erheblich und macht Abhängigkeiten explizit sichtbar, statt sie in Import-Anweisungen zu verstecken.


// useOrders.js — composable accepting its own HTTP client as a parameter
import { ref } from "vue";

export function useOrders(httpClient = defaultHttpClient) {
  const orders = ref([]);
  const isLoading = ref(false);
  const error = ref(null);

  async function fetchOrders() {
    isLoading.value = true;
    error.value = null;
    try {
      orders.value = await httpClient.get("/api/orders");
    } catch (err) {
      error.value = err;
    } finally {
      isLoading.value = false;
    }
  }

  return { orders, isLoading, error, fetchOrders };
}

// useOrders.test.js — injecting a fake HTTP client, no vi.mock needed
import { describe, it, expect, vi } from "vitest";
import { useOrders } from "./useOrders";

describe("useOrders", () => {
  it("populates orders from the injected client", async () => {
    const fakeClient = { get: vi.fn().mockResolvedValue([{ id: 1, total: 42 }]) };
    const { orders, fetchOrders } = useOrders(fakeClient);

    await fetchOrders();

    expect(orders.value).toEqual([{ id: 1, total: 42 }]);
    expect(fakeClient.get).toHaveBeenCalledWith("/api/orders");
  });

  it("sets the error ref when the injected client rejects", async () => {
    const failingClient = { get: vi.fn().mockRejectedValue(new Error("Network down")) };
    const { error, fetchOrders } = useOrders(failingClient);

    await fetchOrders();

    expect(error.value?.message).toBe("Network down");
  });
});

4. Provide/Inject als Testing-Seam

Für Composables, die auf provide/inject statt auf direkte Imports setzen, entsteht eine natürliche Testing-Seam. Da inject() immer einen bestimmten Schlüssel aus dem Komponentenbaum sucht, kann ein Test beim Mounten mit global.provide einfach eine Test-Implementierung unter demselben Schlüssel bereitstellen. Das ist besonders robust, weil weder Modul-Mocking noch Konstruktor-Parameter geändert werden müssen, das Composable selbst bleibt unverändert.

Dieser Ansatz eignet sich besonders für Composables, die als App-weite Services fungieren, etwa ein Theme-Service, ein Feature-Flag-Service oder ein Analytics-Tracker. Statt in jeder Komponente vi.mock() zu wiederholen, reicht ein einziger global.provide-Eintrag im Test-Setup, der für alle Komponenten desselben Tests gilt.


// useFeatureFlags.js — composable exposed via provide/inject
import { inject } from "vue";

export const FEATURE_FLAGS_KEY = Symbol("feature-flags");

export function useFeatureFlags() {
  const flags = inject(FEATURE_FLAGS_KEY);
  if (!flags) {
    throw new Error("Feature flags not provided — wrap the app with the FeatureFlagsProvider");
  }
  return flags;
}

// CheckoutButton.test.js — providing a test double via global.provide
import { describe, it, expect } from "vitest";
import { mount } from "@vue/test-utils";
import CheckoutButton from "./CheckoutButton.vue";
import { FEATURE_FLAGS_KEY } from "@/composables/useFeatureFlags";

describe("CheckoutButton", () => {
  it("shows the express checkout option when the flag is enabled", () => {
    const wrapper = mount(CheckoutButton, {
      global: {
        provide: {
          [FEATURE_FLAGS_KEY]: { expressCheckout: true },
        },
      },
    });

    expect(wrapper.find("[data-testid='express-checkout']").exists()).toBe(true);
  });

  it("hides the express checkout option when the flag is disabled", () => {
    const wrapper = mount(CheckoutButton, {
      global: { provide: { [FEATURE_FLAGS_KEY]: { expressCheckout: false } } },
    });

    expect(wrapper.find("[data-testid='express-checkout']").exists()).toBe(false);
  });
});

5. Mocking von useFetch und useAsyncData in Nuxt

Nuxt-eigene Composables wie useFetch und useAsyncData bringen zusätzliche Komplexität mit, weil sie sowohl Server-Side-Rendering-Kontexte als auch Client-Hydration abdecken müssen. Für Unit-Tests mit Vitest empfiehlt sich, diese Composables ebenfalls über vi.mock() zu ersetzen, statt echte Netzwerkanfragen zu simulieren, da der SSR-Kontext in Unit-Tests ohnehin nicht vollständig vorhanden ist.

Ein häufiger Fehler ist, die Rückgabestruktur von useFetch im Mock zu vereinfachen und dabei Felder wie pending, error oder refresh zu vergessen, die die Komponente tatsächlich verwendet. Ein sauberes Composable-Mock sollte immer dieselbe Struktur wie das Original zurückgeben, auch wenn im konkreten Test nur ein Teil davon relevant ist.


// ProductList.test.js — mocking useFetch with the full expected return shape
import { describe, it, expect, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { ref } from "vue";
import ProductList from "./ProductList.vue";

vi.mock("#app", () => ({
  useFetch: vi.fn(() => ({
    data: ref([{ id: 1, name: "Keyboard" }]),
    pending: ref(false),
    error: ref(null),
    refresh: vi.fn(),
  })),
}));

describe("ProductList", () => {
  it("renders products from the mocked useFetch response", () => {
    const wrapper = mount(ProductList);
    expect(wrapper.text()).toContain("Keyboard");
  });
});

6. Partielles Mocken: vi.spyOn und Teilersatz

Nicht jeder Test braucht ein vollständiges Mock eines Composables. Wenn nur eine einzelne Methode eines ansonsten realen Composables ersetzt werden soll, ist vi.spyOn() in Kombination mit vi.importActual() die passendere Wahl. Das erhält die echte Logik des Composables, ersetzt aber gezielt eine Methode mit Seiteneffekten, etwa einen Analytics-Call, der in Tests nicht wirklich ausgeführt werden soll.

Dieser Ansatz ist besonders wertvoll, wenn ein Composable mehrere zusammenhängende Funktionen exportiert und nur eine davon problematisch für Tests ist. Ein vollständiges Mock würde in diesem Fall unnötig viel echte Logik verstecken und die Aussagekraft des Tests reduzieren.


// useAnalytics.test.js — partial mocking with vi.spyOn, keeping real logic intact
import { describe, it, expect, vi } from "vitest";
import * as analyticsModule from "@/composables/useAnalytics";

describe("Checkout tracking", () => {
  it("calls trackEvent with the correct payload without hitting the real network", async () => {
    const trackSpy = vi.spyOn(analyticsModule, "trackEvent").mockResolvedValue(undefined);

    const { completeCheckout } = analyticsModule.useAnalytics();
    await completeCheckout({ orderId: 42, total: 79.9 });

    expect(trackSpy).toHaveBeenCalledWith("checkout_completed", { orderId: 42, total: 79.9 });
    trackSpy.mockRestore();
  });
});

7. Timer und reaktive Nebenwirkungen isolieren

Composables, die auf setInterval, setTimeout oder Debounce-Funktionen setzen, benötigen zusätzlich zum Mocken der Rückgabewerte auch die Kontrolle über die Zeit selbst. Vitest bietet dafür vi.useFakeTimers(), das echte Timer durch synchron steuerbare Attrappen ersetzt. Ohne Fake Timer müssten Tests mit echten setTimeout-Wartezeiten laufen, was Testsuiten unnötig verlangsamt und in CI-Umgebungen zu Flakiness führt.

Wichtig ist, Fake Timer und Composable-Mocking bewusst zu trennen: Das Composable selbst bleibt unverändert, nur die zugrunde liegende Zeitquelle wird kontrolliert. Nach jedem Test müssen die echten Timer mit vi.useRealTimers() wiederhergestellt werden, sonst beeinflusst ein Test mit Fake Timern nachfolgende Tests in derselben Datei.


// useDebouncedSearch.test.js — controlling time with fake timers
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useDebouncedSearch } from "@/composables/useDebouncedSearch";

describe("useDebouncedSearch", () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it("only triggers the search callback after the debounce delay", () => {
    const onSearch = vi.fn();
    const { search } = useDebouncedSearch(onSearch, 300);

    search("keyboard");
    expect(onSearch).not.toHaveBeenCalled();

    vi.advanceTimersByTime(299);
    expect(onSearch).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1);
    expect(onSearch).toHaveBeenCalledWith("keyboard");
  });
});

8. Fallstricke: Geteilter State zwischen Tests

Der häufigste Fehler beim Composables mocken ist geteilter Zustand zwischen Tests, der aus Singleton-artigen Mock-Implementierungen entsteht. Wenn ein Mock-Objekt in der obersten Ebene einer Testdatei einmalig erzeugt wird und mehrere Tests denselben Mock ohne Reset teilen, sickert Zustand aus einem Test in den nächsten, was zu Tests führt, die nur in bestimmter Reihenfolge grün sind.

Die Lösung ist, in beforeEach() entweder vi.clearAllMocks() aufzurufen oder das Mock-Objekt komplett neu zu erzeugen, statt es einmalig auf Modulebene zu instanziieren. Für Composables mit internem ref-Zustand bedeutet das, die Composable-Factory-Funktion in jedem Test neu aufzurufen, statt eine gemeinsame Instanz über mehrere it()-Blöcke hinweg wiederzuverwenden.

9. Mocking-Strategien im Vergleich

Die Wahl der richtigen Mocking-Strategie hängt davon ab, wie ein Composable in eine Komponente eingebunden ist und wie stark es von externen Nebenwirkungen abhängt.

Strategie Wann geeignet Vorteil Nachteil
vi.mock (Modul) Direkter Import, fest verdrahtete Abhängigkeit Kein Umbau der Komponente nötig Hoisting-Regeln, Boilerplate pro Datei
Dependency Injection Neue Composables, testfreundliches Design Kein Mocking-Framework nötig Erfordert Anpassung der Signatur
provide/inject App-weite Services, Feature Flags Ein Provide-Eintrag für alle Komponenten Nur für inject-basierte Composables
vi.spyOn (partiell) Nur eine Methode ist problematisch Restliche Logik bleibt real und getestet Muss nach dem Test zurückgesetzt werden

In den meisten Vue-Projekten kommen alle vier Strategien parallel zum Einsatz, je nach Composable. Neue Composables profitieren davon, von Anfang an mit Dependency Injection oder provide/inject als Testing-Seam entworfen zu werden, während bestehende, eng gekoppelte Composables meist über vi.mock auf Modulebene getestet werden müssen, bis ein größeres Refactoring ansteht.

Mironsoft

Testbare Composable-Architektur und stabile Vitest-Suiten für Vue-Teams

Composable-Tests, die bei jedem Lauf grün bleiben?

Wir analysieren bestehende Composables, führen testfreundliche Dependency Injection ein und beseitigen geteilten State zwischen Tests, der Suiten flaky macht.

Composable-Review

Testbarkeit bestehender Composables analysieren und Kopplungen aufdecken

Mocking-Strategie

vi.mock, Dependency Injection und provide/inject sinnvoll kombinieren

Flaky-Test-Fixes

Geteilten Mock-State finden und durch saubere beforeEach-Resets ersetzen

10. Zusammenfassung

Composables mocken in Vue-Tests ist keine einheitliche Technik, sondern eine Auswahl aus mehreren Strategien, die je nach Kopplungsgrad des Composables passend gewählt werden muss. vi.mock() auf Modulebene funktioniert für direkt importierte Composables, Dependency Injection macht Mocking bei neuen Composables trivial, und provide/inject bietet eine natürliche Testing-Seam für App-weite Services. Partielles Mocken mit vi.spyOn() erhält echte Logik, wo sie relevant ist, und ersetzt nur problematische Seiteneffekte.

Der größte Stolperstein bleibt geteilter Zustand zwischen Tests, der aus unsauber zurückgesetzten Mocks entsteht. Wer konsequent beforeEach() für Resets nutzt und Fake Timer nach jedem Test wieder deaktiviert, vermeidet die häufigste Ursache für Tests, die nur in bestimmter Reihenfolge grün sind. Composables, die von Anfang an mit Testbarkeit im Hinterkopf entworfen werden, sparen langfristig erheblichen Mocking-Aufwand gegenüber nachträglich getesteten, eng gekoppelten Composables.

Composables mocken in Vue-Tests — Das Wichtigste auf einen Blick

Modul-Mocking

vi.mock() wird per Hoisting vor alle Imports verschoben, immer auf oberster Ebene der Testdatei platzieren.

Dependency Injection

Composables, die Abhängigkeiten als Parameter akzeptieren, brauchen kein Mocking-Framework für Tests.

Provide/Inject

Ein global.provide-Eintrag ersetzt App-weite Services für alle Komponenten in einem Test.

Geteilter State

Mocks in beforeEach() zurücksetzen oder neu erzeugen, sonst sickert Zustand zwischen Tests.

11. FAQ: Composables mocken in Vue-Tests

1Warum vi.mock am Dateianfang?
Vitest hoisted vi.mock vor alle Imports. Bedingte Aufrufe fuehren zu unvorhersehbarem Verhalten.
2DI besser als vi.mock?
Bei neuen Composables mit Abhaengigkeiten als Parameter, kein Mocking-Framework noetig.
3provide/inject mocken?
Test-Implementierung ueber global.provide unter demselben Schluessel bereitstellen.
4Vollstaendig vs. partiell?
Vollstaendig ersetzt alles, partiell mit vi.spyOn behaelt echte Logik und ersetzt nur einzelne Methoden.
5Debounce und Timer testen?
vi.useFakeTimers() und vi.advanceTimersByTime() simulieren Zeitablauf ohne echte Wartezeit.
6Reihenfolgeabhaengige Tests?
Meist geteilter Mock-State, beforeEach mit vi.clearAllMocks() behebt das.
7useFetch in Nuxt mocken?
Ueber vi.mock('#app', ...), Rueckgabestruktur mit data, pending, error, refresh nachbilden.
8Fake Timer wieder deaktivieren?
Ja, mit vi.useRealTimers() in afterEach(), sonst beeinflusst es Folgetests.
9DI bei bestehenden Composables?
Meist nur mit groesserem Refactoring sinnvoll, fuer neue Composables von Anfang an empfehlenswert.
10Strategien kombinieren?
Ja, in der Praxis ueblich, je nach Kopplungsgrad des jeweiligen Composables.