skip to content

How do you build a host for testing a Vue 3 `useMouse()` composable that adds a listener in `onMounted` and removes it in `onUnmounted`?

level: middleimportance: should knowfreq 40%

answer

  1. a component that exists only for the test
  2. setup calls it, render returns nothing
  3. mount runs onMounted, unmount runs cleanup
  4. provide before mount, not after

basics

~20 s

Create a throwaway component whose setup calls useMouse and hands its refs back, mount it so onMounted attaches the listener, dispatch a mousemove on window and assert the refs, then unmount it and check the listener is gone.

solid answer

~40 s

Wrap the composable in a component that exists only for the test. A `withSetup` helper does it with `createApp`: its `setup` calls `useMouse()`, stores the result in a closure and returns a render function that renders nothing, then the helper calls `app.mount()` on a detached element. Mounting runs `onMounted` before `mount` returns, so the listener is attached; the test dispatches a `MouseEvent` on `window` and asserts on `x` and `y`. Calling `app.unmount()` runs `onUnmounted`, and a second event must no longer change the refs. With Vue Test Utils the same host is `mount({ setup() { ... } })` and `wrapper.unmount()`. If the composable also injects, the helper must call `app.provide()` **before** `mount`, because `inject` runs once, inside `setup`.

code

ts · 13 lines
ts
import { withSetup } from './with-setup'
import { useMouse } from './useMouse'

test('tracks the pointer while mounted and stops after unmount', () => {
  const [{ x, y }, app] = withSetup(() => useMouse())

  window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 20 }))
  expect([x.value, y.value]).toEqual([10, 20])

  app.unmount()
  window.dispatchEvent(new MouseEvent('mousemove', { clientX: 99, clientY: 99 }))
  expect([x.value, y.value]).toEqual([10, 20])
})

go deeper

for a junior

Recall that a hook-based composable runs inside a small test component, and that mounting and unmounting that component is what triggers its hooks.

for a middle

Explain how withSetup works: setup calls the composable, a null render function avoids the missing-template warning, mount runs onMounted synchronously, unmount runs onUnmounted.

for a senior

Design the helper so it cannot mislead: take injections before mount, return the app for unmount, and assert that behaviour stops after cleanup rather than just that a spy fired.

for a principal

Decide when a synthetic host is a good investment versus testing through the real consuming component, weighing speed, fidelity and how much of the helper each team must maintain.

## What the host has to supply A `useMouse()` composable that calls `onMounted` and `onUnmounted` only works while a **component instance** is being set up, because those functions register callbacks on the current instance. A test therefore needs a **host component**: a component with no purpose other than calling the composable in its `setup()` and exposing the result. The host has three jobs: 1. Call the composable inside `setup()`, so the hooks have an instance to attach to. 2. Get mounted, so the `onMounted` callbacks actually run. 3. Get unmounted at the end, so the `onUnmounted` callbacks run and cleanup can be asserted. ## A `withSetup` helper on `createApp` The Vue testing guide sketches a helper named `withSetup`. A typed version that also accepts injections looks like this: ```ts import { createApp, type App } from 'vue' export function withSetup<T>( composable: () => T, provide: Record<string | symbol, unknown> = {} ): [T, App] { let result!: T const app = createApp({ setup() { result = composable() return () => null } }) for (const key of Reflect.ownKeys(provide)) app.provide(key, provide[key]) app.mount(document.createElement('div')) return [result, app] } ``` Details that matter: - `setup` returns a **render function** returning `null`, so Vue does not warn that the component is missing a template or render function; - the element is **detached**, created but never added to the document, which is enough for mounting; - `app.mount()` renders synchronously and runs the root's `onMounted` hooks before it returns, so the listener exists as soon as the helper does; - the helper returns the **app** so the test can call `app.unmount()`. ## The provide-before-mount trap The guide's sketch shows the test calling `app.provide(...)` **after** `withSetup` has returned. That cannot reach an `inject()` inside the composable: `setup()` ran during `mount`, `inject` read the provides chain at that moment, and a later `app.provide` changes nothing the composable already holds. Injections must be registered **before** `app.mount()`, which is why the helper above takes them as an argument. ## The same host with Vue Test Utils If the project already uses `@vue/test-utils`, `mount()` accepts a component options object defined inline: - `mount({ setup() { result = useMouse(); return () => null } })` builds the same host; - `global.provide` supplies injections, applied before mounting; - `wrapper.unmount()` triggers `onUnmounted`; - a host with props, passed through `mount`'s `props`, lets you test reactive inputs. The Vue Test Utils guide takes a variant approach: the test component returns the composable's values from `setup`, and the test reads them through `wrapper.vm`. ## What the useMouse test asserts | Step | Action | Expected | |---|---|---| | after mount | read `x.value` | `0`, the initial state | | event | dispatch `new MouseEvent('mousemove', { clientX: 10, clientY: 20 })` on `window` | `x` is `10`, `y` is `20` | | unmount | `app.unmount()` | `onUnmounted` removed the listener | | event again | dispatch another `mousemove` | `x` and `y` keep their old values | The last row is the one that proves cleanup. A spy on `window.removeEventListener` also works, but asserting that behaviour stops is closer to what users experience and survives refactors such as switching to an `AbortController`. ## When a host is overkill If a test only needs `inject` and no hooks, `app.runWithContext()` on an unmounted app is lighter. If the composable is complex, testing it through the real component that uses it may be clearer than a synthetic host. ## Common mistakes with host helpers - **Forgetting to unmount.** The listener stays attached to `window` after the test, so later tests that dispatch `mousemove` also update an old composable, and the cleanup path is never verified. - **Providing after mounting.** As above, injections must exist before `setup` runs. - **Asserting on the spy instead of the behaviour.** A spy on `addEventListener` proves a call happened, not that the refs update; dispatching a real event proves both. - **Sharing one host across tests.** A host mounted in `beforeAll` carries its state from test to test; mount a fresh one per test.

  • The composable reads a ref, and the assertion comes right after dispatching the event. Why is no await needed here?
    The listener sets `x.value` synchronously inside the event handler, and the test reads the ref directly, not the DOM. Only rendered output or watcher callbacks wait for Vue's scheduler; a plain ref read sees the new value immediately.
  • A colleague's withSetup test calls `app.provide('logger', fake)` after the helper returns, and the composable still sees undefined. Why?
    `inject` runs once, inside `setup`, and `setup` ran during `app.mount()` inside the helper. By the time the test calls `app.provide`, the composable already holds `undefined`. Pass injections into the helper and register them before mounting, or use Vue Test Utils' `global.provide`, which is applied before mount.

A host component is a stage built for one rehearsal: the actor (the composable) can only hit their cues (lifecycle hooks) when there is a stage with the curtain going up and coming down.

saying these in an interview costs you the question

  • onMounted callbacks run on a later tick, so the test must wait before dispatching events.
  • Calling app.provide after mounting still reaches the composable's inject call.
  • The host must be attached to the real document, or mounting will not happen.
  • Cleanup cannot be tested without spying on removeEventListener.
  • A host component needs a template, so render functions cannot be used.