Testing Reusable Logic in Isolation
Composables are the most important reuse mechanism in Vue 3, but how do you test reactive logic that registers lifecycle hooks, depends on timers, or makes fetch calls? Composable testing is not a special case of component testing, it is its own approach with its own tools and patterns.
Table of Contents
- 1. Why composable testing is its own approach
- 2. Setting up Vitest and @vue/test-utils
- 3. The withSetup wrapper: testing composables in a reactive context
- 4. Testing reactivity: using nextTick and flushPromises correctly
- 5. Simulating lifecycle hooks: onMounted and onUnmounted in tests
- 6. Timer mocking: testing useDebounce and useInterval
- 7. Fetch mocking: testing useFetch composables without a real API
- 8. Mocking Pinia stores in composable tests
- 9. Comparing testing strategies
- 10. Summary
- 11. FAQ
1. Why composable testing is its own approach
Vue composables are regular JavaScript functions, which in principle makes them testable like any other function. The problem arises when the composable uses Vue-specific features: onMounted and onUnmounted require an active component instance. inject() expects a provide context within the component tree. watch and watchEffect stop automatically once the component instance is unmounted. Calling a composable directly outside a component context leads to warnings and unexpected behavior, because lifecycle hooks find no instance to attach to.
For Vue Composable Testing you therefore need a test environment that provides a minimal reactive component context. The most common solution is a withSetup wrapper: a helper function that creates a minimal Vue component with mountComponent or mount from @vue/test-utils, calls the composable inside its setup() function, and passes the return values back out. This wrapper is the foundation of every composable testing strategy for composables with lifecycle hooks.
Composables without lifecycle hooks, pure reactivity logic with ref, computed and watch, can under certain conditions be called directly in tests without a wrapper. effectScope() from Vue provides a reactive context for watch and watchEffect without creating a full component instance. That is lighter weight than the withSetup wrapper and the recommended choice for simple Vue composable tests.
2. Setting up Vitest and @vue/test-utils
Vitest is the natural choice for Vue Composable Testing, because it uses the same Vite build stack that is standard in modern Vue 3 projects. The configuration in vitest.config.ts sets the environment to jsdom or happy-dom, the latter being faster and sufficient for most composable testing scenarios. @vue/test-utils is the official Vue testing library and provides mount, shallowMount and flushPromises.
The configuration for global test setups, setupFiles in Vitest, allows you to initialize Pinia globally, register global components and set up mocks that should be available in every test. A global beforeEach hook with setActivePinia(createPinia()) ensures that every test gets a fresh Pinia instance, essential for isolated Vue Composable Testing with store-dependent composables. This prevents test contamination from state left over by previous 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. The withSetup wrapper: testing composables in a reactive context
The withSetup wrapper is the universal tool for Vue Composable Testing with lifecycle hooks. It creates a minimal Vue application, mounts it to a fresh DOM element, calls the composable within the setup() context, and returns the return values together with an unmount function. The unmount function is crucial: it triggers onUnmounted hooks and lets you test whether the composable cleans up correctly.
A complete composable test with withSetup follows the arrange-act-assert pattern. Arrange: initialize the composable with test data. Act: call methods or change state. Assert: check the return values with reactive assertions. At the end of the test: call unmount() to trigger cleanup and avoid memory leaks in the test runner. This lifecycle test, does the composable correctly set up an event listener in onMounted and remove it in onUnmounted, is especially important for browser API composables.
As an alternative to withSetup, @vue/test-utils offers the option of mounting the composable directly inside a test component. That is more verbose but allows testing the composable in combination with real template rendering, useful for composables that use template refs. For pure logic tests without template interaction, withSetup is the leaner choice.
4. Testing reactivity: using nextTick and flushPromises correctly
Vue reactivity is asynchronous: when a ref value changes, watchers and computed properties are not recalculated immediately but in the next microtask queue. This means: right after a state change, the new value is visible in ref.value, but dependent computed properties and watch callbacks have not reacted yet. Vue Composable Testing must resolve this asynchrony explicitly with await nextTick().
flushPromises() from @vue/test-utils waits for all pending promises and microtasks, more useful than nextTick() for composables with async operations such as fetch calls. After await flushPromises(), all async effects should be complete and reactive assertions should be correct. The most common test bug in Vue Composable Testing: a missing await nextTick() or await flushPromises() after state changes, leading to incorrect assertions against values that have not yet updated.
// 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. Simulating lifecycle hooks: onMounted and onUnmounted in tests
Testing composables that use onMounted and onUnmounted requires the withSetup wrapper and an explicit unmount(). A typical test scenario: a useEventListener composable that registers an event listener in onMounted and removes it in onUnmounted. The test checks whether the listener is registered after mount, and whether it no longer fires after unmount.
Spies with vi.spyOn(window, 'addEventListener') and vi.spyOn(window, 'removeEventListener') let you verify the exact count and arguments of the DOM API calls without triggering real events. That is more robust than testing whether a callback function was invoked, because it verifies the composable's internal implementation directly. After unmount, removeEventListener should have been called with the same arguments as addEventListener, the test verifies complete cleanup.
An advanced composable testing pattern for onMounted: tests that check whether asynchronous initialization inside onMounted runs correctly. Since onMounted runs synchronously right after mount, but async operations inside onMounted are not awaited, the test must call await flushPromises() after mount to wait for the async operations to finish. This is a common pitfall when testing Vue composables.
6. Timer mocking: testing useDebounce and useInterval
Composables that use setTimeout, setInterval or requestAnimationFrame cannot be tested with real timers without slowing tests down to seconds. Vitest offers vi.useFakeTimers(), which replaces all browser timers with synchronously controllable fake timers. With vi.advanceTimersByTime(300) you jump the test timeline forward by 300 milliseconds without actually waiting. That makes Vue Composable Testing of debounce, throttle and interval composables fast and deterministic.
Cleaning up after timer tests matters: vi.useRealTimers() in the afterEach hook ensures that other tests are not affected by the fake timers. Alternatively, you configure fake timers in beforeEach and restore them in afterEach. Vue Composable Tests for interval composables typically check whether the interval is stopped after unmount, by advancing time after unmount and confirming that the callback no longer fires.
// 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: testing useFetch composables without a real API
Composables that call fetch must be tested against mocked responses in tests. Vitest offers vi.stubGlobal('fetch', vi.fn()) to replace fetch with a mock function. The mock function returns a promise that simulates a response-like structure with ok, status and a json() method. For more complex scenarios, different responses for different URLs, network errors, timeouts, the msw library (Mock Service Worker) is the more robust choice.
Composable Testing for fetch composables follows the pattern: mock fetch, instantiate the composable, wait with await flushPromises(), make assertions against data, loading and error. Tests for the failure case are at least as important as tests for the success case: what happens if response.ok is false? What happens on a network error? The composable should correctly set the error ref in both cases and reset loading to false. Tests that cover these scenarios are the most valuable part of the Vue Composable Testing suite.
An important aspect when testing useFetch composables: testing reactive URLs. If the composable automatically refetches when the URL changes, the test must change the URL ref, await nextTick() for the watch trigger, and then await flushPromises() for the new fetch. Tests with reactive parameters verify the composable's entire watch-fetch-state chain.
8. Mocking Pinia stores in composable tests
Composables that internally call Pinia stores can be tested in two ways: with real store instances or with mocked stores. Using real stores in tests is often the simpler choice, provided every test gets a fresh Pinia instance via setActivePinia(createPinia()). The store state can then be manipulated directly: authStore.user = testUser, without mock boilerplate. For Vue Composable Testing with simple store dependencies, this approach is preferable.
For composables that depend on stores with complex actions or external dependencies, store mocking makes more sense. Vitest lets you mock a store module with vi.mock('@/stores/useAuthStore') and a mock implementation that implements the store interface. That fully isolates the composable from its store dependencies and makes tests faster and more deterministic. The downside: mock implementations can drift from the real store interface when the store changes, TypeScript helps here by immediately flagging type mismatches.
| Composable type | Testing approach | Tool | Async handling |
|---|---|---|---|
| Pure reactivity | effectScope directly | Vue effectScope | nextTick |
| With lifecycle hooks | withSetup wrapper | createApp + mount | nextTick + unmount() |
| With timer/interval | Fake timer | vi.useFakeTimers() | advanceTimersByTime |
| With fetch/API | Fetch mock / MSW | vi.stubGlobal / msw | flushPromises |
| With Pinia store | Real store or mock | createPinia / vi.mock | nextTick + flushPromises |
9. Comparing testing strategies
The strategy for Vue Composable Testing depends heavily on the type of composable. Simple calculation logic without lifecycle hooks and without external dependencies can be tested with a simple test function and no wrapper. That is the fastest and most direct form of composable testing and should be preferred whenever possible. The more complex the external dependencies, the more setup the test needs, but the more valuable the test also becomes, because it verifies the integration of different parts of the composable.
The most important principle in Vue Composable Testing: test behavior, not implementation. A test that checks whether onMounted was called is an implementation test. A test that checks whether the event listener is active after mount is a behavior test. Behavior tests are more robust against refactoring: if the composable changes its internal lifecycle timing, the behavior test does not need to be adjusted.
Mironsoft
Vue 3 testing, Vitest setup and composable test architecture
Need a composable test architecture for your Vue 3 project?
We set up Vitest, @vue/test-utils and MSW for your project and develop a testing strategy for all composable types, from simple reactivity to complex store-dependent workflows.
Test setup
Set up and configure Vitest, happy-dom, MSW and Pinia test infrastructure
Test development
Add tests retroactively to existing composables and establish the withSetup pattern across the codebase
CI integration
Integrate Vitest into CI/CD pipelines with coverage reports and branch protection
10. Summary
Vue Composable Testing is not an extension of component testing, it is its own approach that accounts for the particularities of composables: reactive context, lifecycle hooks, timers, fetch calls and store dependencies. The withSetup wrapper is the universal tool for composables with lifecycle hooks. vi.useFakeTimers() makes timer-based composables deterministically testable. Fetch mocking with vi.stubGlobal or MSW enables isolated API tests. Fresh Pinia instances per test prevent state contamination.
The investment in composable tests pays off in proportion to how widely the composable is reused. A composable used in twenty components, whose core logic gets corrupted by a bug, breaks twenty features at once. Three tests for that composable, the normal case, the failure case, an edge case, protect all twenty components at once. That is the core advantage of composable testing over component integration tests.
Vue Composable Testing: The essentials at a glance
withSetup wrapper
Minimal Vue app for a reactive context. Required for composables with lifecycle hooks. Call unmount() at the end of the test.
Async handling
nextTick() after state changes. flushPromises() after async operations. Both are often needed in combination.
Timer & fetch
vi.useFakeTimers() for deterministic timer testing. vi.stubGlobal('fetch') or MSW for API mocking.
Pinia in tests
setActivePinia(createPinia()) per test in beforeEach. Real stores for simple dependencies, vi.mock for complex ones.