Skip to content
intermediate Phase 13 · Testing with Vitest and Vue Test Utils

Testing Composables in Isolation

Exercise useX functions without mounting any component at all.

35m
0 problems
Topic Progress 0%

Call the composable directly and assert on the refs it returns

A composable is a function that returns reactive values, which means you can test much of it without rendering anything at all. useCart() builds refs, a computed total, and maybe a watch — none of that requires a template, a DOM node, or a mounted component. Call the function, poke the refs, assert the results.

import { useCart } from '@/composables/useCart'

test('computes the total including tax', () => {
  const cart = useCart()

  cart.items.value.push({ price: 20, qty: 2 })

  expect(cart.total.value).toBe(48)   // 40 + 20% tax
  expect(cart.count.value).toBe(2)
})

This works because the setup APIs are ordinary exported functions. ref, computed, reactive, watch, watchEffect, and effectScope never check for a component instance — they attach to whatever effect scope is active, or create their own. Vue's documentation positions composables as the Composition API's equivalent of mixins precisely because they are decoupled from any single component, and tests inherit that decoupling for free.

What does not work outside a component is just as important to know before you write the test:

API Works with no instance? What actually happens
ref / computed / reactive yes behaves exactly as in setup()
watch / watchEffect yes schedules through Vue's job queue
effectScope() yes this is how you stop watchers yourself
onMounted / onUnmounted no warns "onMounted is called when there is no active component instance" and never runs
inject() no warns and returns the fallback value
getCurrentInstance() no returns null

So the direct-call style is the right tool when the composable is pure logic over reactive state: reducers, formatters, pagination math, filtering, state machines. Assert on the returned refs and computeds, not on DOM output — there is no DOM here, and reaching for one would just mean re-testing the component that consumes the composable.

Two scheduling details keep these tests honest. First, watchers use Vue's scheduler even outside a component, so a watch with default flush: 'pre' runs on a microtask after your synchronous assertion would fire. Await nextTick(), or wrap the expectation in vi.waitFor(...) when the composable debounces:

test('derives the query after typing', async () => {
  const search = useSearch()
  search.term.value = 'vue'
  await nextTick()
  expect(search.query.value).toBe('q=vue')
})

Second, nothing disposes those watchers once the test ends. A leaked watcher from test A still fires during test B, referencing stale mocks from a file that has moved on. Wrap the composable in an explicit scope when the file runs many of them:

import { effectScope } from 'vue'

const scope = effectScope()
const cart = scope.run(() => useCart())
// ... assertions ...
scope.stop()   // every watcher the composable created is gone

Be clear with yourself about what this test proves. It proves the logic, not the wiring — that the consuming component actually calls useCart() and renders total correctly is a different claim, and a small test in Mounting Components in Tests style is what covers it. The combination of a fast logic test plus one thin render test gives full coverage without duplicating the assertions.

The direct call hits its ceiling the moment the composable touches lifecycle hooks or inject(). That is the signal to give it an instance — the harness in the next chapter does exactly that with a few lines.

One more boundary worth naming before you move on: assertions against a watch need the scheduler to have run. Vue queues watcher callbacks through the same job queue as renders, so the reliable pattern is a value change, await nextTick(), then the assertion — or vi.waitFor when the composable's own timing (a debounce, a poll) sits between the change and the effect. The waiting discipline is identical to the one in Testing Async Updates; only the absence of a DOM changes.

Mount a harness when the composable needs lifecycle hooks

The moment a composable calls onMounted, onUnmounted, provide, or inject, a direct function call stops working: Vue warns that there is no active component instance, and the hook body never runs. The fix is to manufacture an instance cheaply — mount a harness component whose only job is to call your composable.

The smallest version uses createApp directly, which is enough when you only need setup() and lifecycle hooks to execute:

import { createApp } from 'vue'

export function withSetup(composable) {
  let result
  const app = createApp({
    setup() {
      result = composable()
      return () => null        // harness renders nothing
    },
  })
  app.mount(document.createElement('div'))
  return [result, () => app.unmount()]
}

Usage reads almost like the direct call:

import { withSetup } from '@/test/withSetup'
import { usePolling } from '@/composables/usePolling'

test('polls on an interval and stops after unmount', () => {
  vi.useFakeTimers()
  const [poller, unmount] = withSetup(() => usePolling('/api/status', 1000))

  expect(poller.status.value).toBe('idle')
  vi.advanceTimersByTime(1000)
  expect(poller.status.value).toBe('polling')

  unmount()                    // runs onUnmounted cleanup inside the composable
  expect(vi.getTimerCount()).toBe(0)
  vi.useRealTimers()
})

Because the initial mounted hook fires during mount() for a synchronous component, the composable's lifecycle work has already run by the time withSetup returns. If the composable's own setup() is async, the harness suspends: mount it, then await flushPromises() from @vue/test-utils before touching result.

Three details separate a reliable harness from a subtly broken one:

  • Return shape. result is the object your composable returned outside a render context, so refs are not auto-unwrapped: assert result.count.value, not result.count. Render-context unwrapping is a template feature, and the harness renders nothing anyway.
  • Need for the DOM or plugins? Use mount() from @vue/test-utils instead of createApp when the composable writes to a template you want to query, or when it needs provide values you want to inject per test via global.provide. The harness component becomes the mounted root and wrapper.unmount() is your teardown.
  • Teardown discipline. Always call the returned unmount function in afterEach. An un-unmounted harness leaves its watchers and timers alive for the rest of the file, and the symptom is a test that passes alone but fails in the full run.

The mount variant earns its place as soon as you want DOM assertions over the harness rather than over the production component:

import { mount } from '@vue/test-utils'

const wrapper = mount({
  setup() {
    const { isWide } = useMediaQuery('(min-width: 768px)')
    return { isWide }
  },
  template: '<div :class="{ wide: isWide }">panel</div>',
}, {
  global: { provide: { breakpointBase: 768 } },   // injects what the composable needs
})

expect(wrapper.classes()).toContain('wide')
wrapper.unmount()

Here the harness supplies provide values, the composable registers its matchMedia listener inside mounted, and the template proves the reactive value actually reaches rendering code. Initial mounted hooks run before mount() returns, but effects the composable schedules in setup() still flush on a microtask — keep await nextTick() before assertions that depend on a watcher.

A realistic case for the harness is a media-query composable: it registers a matchMedia listener on mount and removes it on unmount. You stub window.matchMedia with vi.stubGlobal, mount the harness, flip the stubbed matches value, assert the reactive isWide ref, then unmount and assert the listener was removed with vi.mocked(window.addEventListener) or a spy on the stub. That covers registration, reaction, and cleanup — the three things a direct call can never observe.

Notice the division of labor taking shape across this topic: pure logic gets asserted by calling the function directly, instance-bound behavior gets a harness, and the consuming component keeps its own thin render test from Mounting Components in Tests. What neither style controls yet is shared state that survives between tests — module-scope refs inside the composable file itself, which the next chapter hunts down.

Reset module-scope state so test order stops mattering

The suite passes locally, fails on CI, and the failure moves when you re-run with --sequence.shuffle. The cause is almost always the same: your composable keeps state at module scope, and the test that ran first left data behind for the test that ran second.

// composables/useAuth.js
const user = ref(null)          // module scope — created ONCE per module instance
const token = ref(null)

export function useAuth() {
  async function login(email) { /* writes user and token */ }
  return { user, token, login }
}

Every call to useAuth() returns the same two refs, because the module was evaluated once when the test file imported it. Test A logs in and asserts user.value.email; test B asserts a logged-out state and sees test A's user. Run test B alone and it passes — the classic order-dependent failure, and the reason to run your suite with shuffled ordering at least once before trusting it.

You have three legitimate repairs, in increasing order of preference:

  1. Reset in beforeEach. Expose a $reset() from the composable (or assign user.value = null directly) and call it before each test. Explicit, easy to review, but every new test file has to remember it.
  2. Re-import the module per test. vi.resetModules() clears Vitest's module registry, so a dynamic import() afterwards evaluates the module fresh:
beforeEach(() => {
  vi.resetModules()
})

test('starts logged out', async () => {
  const { useAuth } = await import('@/composables/useAuth')
  const { user } = useAuth()
  expect(user.value).toBeNull()
})

This guarantees pristine state without touching production code, at the cost of a dynamic import in every test.
3. Move the state somewhere per-app. When the shared state is genuinely global (auth, cart, locale), that state belongs in a Pinia store or in the composable's owning component — created fresh for each mounted app — rather than in a module-level ref the module system caches for you. This is a design fix: module scope is a hidden singleton, and singletons are exactly what tests cannot isolate.

Whichever repair you choose, extend the same hygiene to the rest of the file's shared machinery. Clear call history with vi.clearAllMocks() in afterEach, stop explicit effectScope() wrappers, restore real timers after fake ones, and unmount every harness helper you created so no instance outlives its test. A watcher left alive from test A will happily read the mock that test B just reconfigured.

Order dependence is not the only symptom of shared state; intermittent async failures are the other. If a watch from a previous test fires during the next one and mutates a mock you already asserted on, the failure points at the wrong test entirely. The symptom-to-fix map is small enough to memorize:

Symptom in the suite Likely cause Fix
test passes alone, fails in the full run module-scope ref carrying data $reset() or vi.resetModules()
failure moves when ordering changes live watcher from a previous test scope.stop() / unmount in teardown
mock returns another test's data stale call history or implementation vi.clearAllMocks() in afterEach
two tests both observe a "logged-in" state singleton auth state move it into a per-app store

Determinism in composable tests therefore has two halves: each test starts from the state the module had on first load, and each test leaves no watchers or timers running when it exits.

Get that right and the suite becomes safe to run in any order, in parallel, and on every commit — which is the prerequisite for the next level of control: replacing whole modules and child components with doubles, covered in Mocking Modules and Stubbing Children. If you arrived here from Testing Async Updates, you already own the waiting discipline these tests also depend on.

Quiz

1. Why can useCart() be tested by invoking it like a plain function?

Question 1 options

2. A composable starts a poll interval inside onMounted and clears it in onUnmounted. What is the minimal correct test setup?

Question 2 options

3. useAuth keeps `const user = ref(null)` at module scope, and test B starts with test A's logged-in user. What fixes it?

Question 3 options

4. Your harness returns `{ count }` where count is a ref created by the composable. How do you assert it?

Question 4 options

Flashcards

Question

Which setup APIs work outside a component, and which warn instead?

Answer

Work: ref, computed, reactive, watch, watchEffect, effectScope. Warn and no-op without an instance: onMounted/onUnmounted and other lifecycle hooks, inject(), provide(); getCurrentInstance() returns null.

Question

What does a withSetup helper do, and what must its caller always do with it?

Answer

It creates a minimal app (createApp with a setup that calls the composable and renders null), mounts it so hooks run, and returns [result, unmount]. The caller must invoke unmount in teardown, or watchers and timers leak into later tests.

Question

How do you assert a value produced by the composable's watcher?

Answer

Change the source, await nextTick() (or use vi.waitFor when a debounce/poll sits in between), then assert — watchers run through Vue's scheduler, so a synchronous assertion runs too early.

Question

Why does a composable test pass alone but fail in the full run?

Answer

Module-scope state, live watchers, or uncleared timers survive from an earlier test. Fix with a reset in beforeEach, vi.resetModules() plus dynamic import, effectScope().stop()/unmount, and vi.clearAllMocks() in afterEach.

Question

Where should each assertion live: composable logic or consuming component?

Answer

Pure logic (math, filtering, state machines) is asserted directly on returned refs and computeds; wiring — that the component calls the composable and renders its values — gets one thin mount test. Two small tests beat one broad one.

Revision Notes

Key Takeaways

  • 1. Composables are plain functions over reactive primitives, so most of them need no component at all — assert directly on returned refs and computeds
  • 2. Lifecycle hooks and inject() require an active instance; a three-line withSetup harness supplies one and must unmount in teardown
  • 3. Watchers use Vue's scheduler even outside components — change, await nextTick (or vi.waitFor), then assert
  • 4. Module-scope refs are hidden singletons and the main source of order-dependent failures; reset, re-import, or relocate them
  • 5. Logic tests and wiring tests make different claims — keep each thin and put it on its own side of the boundary

Interview Tips

  • • "How do you test a composable?" — split the answer: call it directly for pure logic, harness it for lifecycle-bound behavior, and name the APIs that fail without an instance (onMounted, inject)
  • • "What breaks when tests share module state?" — describe the pass-alone/fail-together symptom and give two fixes: explicit reset or vi.resetModules with dynamic import
  • • "How do you assert on a watcher's output?" — change the source, await nextTick or vi.waitFor, then assert; mention flush: 'sync' only as a test-side convenience if asked
  • • "How do you know a composable test is enough?" — it proves the returned logic; one thin mount test on the consuming component proves the wiring, and you say which claim each test makes

Cheat Sheet

Testing Composables in Isolation

Direct call (pure logic):

const cart = useCart()
cart.items.value.push({ price: 20, qty: 2 })
expect(cart.total.value).toBe(48)
await nextTick()                  // watchers flush on the scheduler

Harness (lifecycle / inject / DOM):

export function withSetup(composable) {
  let result
  const app = createApp({ setup() { result = composable(); return () => null } })
  app.mount(document.createElement('div'))
  return [result, () => app.unmount()]
}
Need Tool
refs/computed/watch only call directly
onMounted / onUnmounted harness (createApp or mount)
provide values global.provide on a mount harness
DOM assertions over the harness mount with a tiny template

Isolation rules: module-scope state → $reset() / vi.resetModules() + dynamic import / move to a per-app store; watchers → effectScope().stop() or unmount; mocks → vi.clearAllMocks() in afterEach; verify with vitest --sequence.shuffle.

Continue Learning