clean isolation without hidden shared state
Mocking composables is more complicated in Vue tests than it first appears, because composables often combine global state, network access, and reactive side effects. With vi.mock, targeted dependency injection, and provide/inject as a testing seam, components can still be tested reliably in isolation, without dragging along real composable implementations.
Table of contents
- 1. Why composables bring special mocking challenges
- 2. Base structure: vi.mock for composable modules
- 3. Dependency injection instead of global imports
- 4. Provide/inject as a testing seam
- 5. Mocking useFetch and useAsyncData in Nuxt
- 6. Partial mocking: vi.spyOn and partial replacement
- 7. Isolating timers and reactive side effects
- 8. Pitfalls: shared state between tests
- 9. Mocking strategies compared
- 10. Summary
- 11. FAQ
1. Why composables bring special mocking challenges
A composable in Vue encapsulates reusable, reactive logic, often with its own internal state, network calls, and side effects such as event listeners or timers. This exact encapsulation, which makes composables so valuable in application development, is what makes mocking composables harder in tests. Unlike a pure utility function with an input value and a return value, a composable often holds references to global singletons, such as an HTTP client or a global event bus, which complicates isolated testing of a component that uses that composable.
A second problem is the tight coupling between composable and component through direct imports. When a component writes import { useAuth } from '@/composables/useAuth' and calls the function directly inside the setup() block, there is no obvious point of entry without additional tooling to inject a mocked version in the test. This is exactly where the techniques in this article start, beginning with module mocking via vi.mock, moving through dependency injection, and ending at provide/inject as an explicit testing seam.
Mocking composables therefore means not just replacing a function with a dummy, but deliberately deciding at which boundary in the code that replacement happens, and how that boundary stays consistent across many tests without tests affecting each other through shared state.
2. Base structure: vi.mock for composable modules
The most direct way to mock a composable is vi.mock() at the module level. Vitest replaces the entire module with a dummy before the component under test imports it. This works reliably as long as the composable is a named export from its own file, and the component includes it via a relative or aliased import.
It is important to always place vi.mock() at the top level of the test file, since Vitest hoists these calls above all imports. Anyone trying to call vi.mock() conditionally or inside a test function experiences unpredictable behavior, because hoisting ignores the actual code order.
// 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);
});
});
The downside of this approach: every test file that needs the same composable in a different configuration, say a logged out user, has to restructure the mock inside the file or adjust it at runtime with vi.mocked(). For simple, rarely used composable calls that is acceptable, but for widely used composables like useAuth, a central factory function for the mock, reused across multiple test files, pays off.
3. Dependency injection instead of global imports
A more robust alternative to module mocking is to hand composables to a component through parameters from the start, instead of through direct imports. This form of dependency injection makes mocking composables trivial, because testing simply passes a different implementation as a prop or function parameter, without needing module mocking at all.
In practice, that means a component does not import and call useOrders() directly, but instead accepts a factory function as an optional parameter that points to the real composable in production. This approach slightly increases signature complexity, but considerably reduces the need for mocking frameworks and makes dependencies explicitly visible instead of hiding them in import statements.
// 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 as a testing seam
For composables built on provide/inject instead of direct imports, a natural testing seam emerges. Since inject() always looks up a specific key in the component tree, a test can simply supply a test implementation under the same key using global.provide at mount time. This is especially robust because neither module mocking nor constructor parameters need to change, the composable itself stays untouched.
This approach is particularly well suited for composables that act as app wide services, such as a theme service, a feature flag service, or an analytics tracker. Instead of repeating vi.mock() in every component, a single global.provide entry in the test setup applies to all components in the same test.
// 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 useFetch and useAsyncData in Nuxt
Nuxt's own composables such as useFetch and useAsyncData bring additional complexity, because they have to cover both server side rendering contexts and client hydration. For unit tests with Vitest, it is worth replacing these composables via vi.mock() too, instead of simulating real network requests, since the SSR context is not fully present in unit tests anyway.
A common mistake is simplifying the return shape of useFetch in the mock and forgetting fields such as pending, error, or refresh that the component actually uses. A clean composable mock should always return the same structure as the original, even if only part of it is relevant for the specific test.
// 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. Partial mocking: vi.spyOn and partial replacement
Not every test needs a full mock of a composable. When only a single method of an otherwise real composable needs replacing, vi.spyOn() combined with vi.importActual() is the better choice. This keeps the composable's real logic intact but deliberately replaces one method that has side effects, such as an analytics call that should not actually run in tests.
This approach is especially valuable when a composable exports several related functions and only one of them is problematic for tests. A full mock would in that case unnecessarily hide a lot of real logic and reduce the meaningfulness of the test.
// 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. Isolating timers and reactive side effects
Composables that rely on setInterval, setTimeout, or debounce functions need control over time itself, in addition to mocking return values. Vitest provides vi.useFakeTimers() for this, which replaces real timers with synchronously controllable dummies. Without fake timers, tests would have to run with real setTimeout wait times, which unnecessarily slows down test suites and leads to flakiness in CI environments.
It is important to keep fake timers and composable mocking deliberately separate: the composable itself remains unchanged, only the underlying time source is controlled. Real timers must be restored after every test with vi.useRealTimers(), otherwise a test using fake timers affects subsequent tests in the same file.
// 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. Pitfalls: shared state between tests
The most common mistake when mocking composables is shared state between tests, arising from singleton like mock implementations. When a mock object is created once at the top level of a test file and multiple tests share the same mock without a reset, state leaks from one test into the next, producing tests that only pass in a specific order.
The fix is to either call vi.clearAllMocks() in beforeEach(), or recreate the mock object entirely instead of instantiating it once at module level. For composables with internal ref state, that means calling the composable factory function fresh in every test, instead of reusing a shared instance across multiple it() blocks.
9. Mocking strategies compared
Choosing the right mocking strategy depends on how a composable is wired into a component and how strongly it depends on external side effects.
| Strategy | When suitable | Advantage | Drawback |
|---|---|---|---|
| vi.mock (module) | Direct import, hard wired dependency | No component rewrite needed | Hoisting rules, boilerplate per file |
| Dependency injection | New composables, test friendly design | No mocking framework needed | Requires adjusting the signature |
| provide/inject | App wide services, feature flags | One provide entry covers all components | Only for inject based composables |
| vi.spyOn (partial) | Only one method is problematic | Remaining logic stays real and tested | Must be restored after the test |
Most Vue projects end up using all four strategies in parallel, depending on the composable. New composables benefit from being designed with dependency injection or provide/inject as a testing seam from the start, while existing, tightly coupled composables usually have to be tested via vi.mock at the module level until a larger refactor comes along.
Mironsoft
Testable composable architecture and stable Vitest suites for Vue teams
Composable tests that stay green on every run?
We analyze existing composables, introduce test friendly dependency injection, and eliminate shared state between tests that makes suites flaky.
Composable review
Analyzing testability of existing composables and uncovering couplings
Mocking strategy
Combining vi.mock, dependency injection, and provide/inject sensibly
Flaky test fixes
Finding shared mock state and replacing it with clean beforeEach resets
10. Summary
Mocking composables in Vue tests is not a single technique but a choice among several strategies, one that must be picked according to how tightly coupled the composable is. vi.mock() at the module level works for directly imported composables, dependency injection makes mocking trivial for new composables, and provide/inject offers a natural testing seam for app wide services. Partial mocking with vi.spyOn() preserves real logic where it matters and replaces only problematic side effects.
The biggest pitfall remains shared state between tests, arising from mocks that are not reset cleanly. Anyone who consistently uses beforeEach() for resets and disables fake timers again after every test avoids the most common cause of tests that only pass in a specific order. Composables designed with testability in mind from the start save considerable mocking effort in the long run, compared to tightly coupled composables tested only after the fact.
Mocking Composables in Vue Tests — the essentials at a glance
Module mocking
vi.mock() is hoisted above all imports, always place it at the top level of the test file.
Dependency injection
Composables that accept dependencies as parameters need no mocking framework for tests.
Provide/inject
A single global.provide entry replaces app wide services for all components in a test.
Shared state
Reset or recreate mocks in beforeEach(), otherwise state leaks between tests.