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.
resultis the object your composable returned outside a render context, so refs are not auto-unwrapped: assertresult.count.value, notresult.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-utilsinstead ofcreateAppwhen the composable writes to a template you want to query, or when it needsprovidevalues you want to inject per test viaglobal.provide. The harness component becomes the mounted root andwrapper.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.
Quiz
1. Why can useCart() be tested by invoking it like a plain function?
2. A composable starts a poll interval inside onMounted and clears it in onUnmounted. What is the minimal correct test setup?
3. useAuth keeps `const user = ref(null)` at module scope, and test B starts with test A's logged-in user. What fixes it?
4. Your harness returns `{ count }` where count is a ref created by the composable. How do you assert it?
Flashcards
Question
Which setup APIs work outside a component, and which warn instead?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.