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.
Inhaltsverzeichnis
- 1. Warum Composables besondere Mocking-Herausforderungen bringen
- 2. Grundstruktur: vi.mock für Composable-Module
- 3. Dependency Injection statt globaler Imports
- 4. Provide/Inject als Testing-Seam
- 5. Mocking von useFetch und useAsyncData in Nuxt
- 6. Partielles Mocken: vi.spyOn und Teilersatz
- 7. Timer und reaktive Nebenwirkungen isolieren
- 8. Fallstricke: Geteilter State zwischen Tests
- 9. Mocking-Strategien im Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.