Vue Composable Testing: Testing Reusable Logic in Isolation
AI generated
<v/>
{ }
Vue 3 · Vitest · Testing · Composables
Vue Composable Testing
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.

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

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.

11. FAQ: Vue Composable Testing

1Why not test directly outside setup()?
Lifecycle hooks need an active instance. The withSetup wrapper creates this context. Without it, hooks silently do nothing or warn.
2nextTick vs. flushPromises?
nextTick: one reactivity flush. flushPromises: all pending promises. For async fetch: flushPromises. Often combined.
3Cleanup test for event listeners?
vi.spyOn(window, 'removeEventListener'), call withSetup + unmount(), then verify the spy was called with matching arguments.
4Testing fake timers?
vi.useFakeTimers() in beforeEach, vi.useRealTimers() in afterEach. advanceTimersByTime() for time jumps without real waiting.
5vi.stubGlobal fetch or MSW?
vi.stubGlobal for simple scenarios. MSW for complex API mocking with URL routing, errors and network simulation.
6Fresh Pinia per test?
Stores are singletons. Without a fresh instance, state carries over between tests. setActivePinia(createPinia()) in beforeEach.
7effectScope instead of withSetup?
Yes, for composables without lifecycle hooks. Lighter weight than withSetup. Preferred for pure reactivity tests.
8Testing reactive URLs in useFetch?
Create a URL ref, instantiate, flushPromises(). Then change the URL, nextTick(), flushPromises(), check assertions.
9Test behavior instead of implementation?
Behavior tests survive refactoring. Not "was onMounted called?" but rather "is the listener active after mount?"
10How much coverage for composables?
Critical, frequently used composables close to 100%. The more components share it, the more valuable the test. One-off utilities can have less.