Testing Logic and Composables

Test pure functions directly, call a composable and assert on its refs, and use withSetup when it needs a component.

Syntaxconst { count, increment } = useCounter(0) // count.value, not count

Most of the logic worth testing is not a component. It is a formatter, a validator, or a composable: a plain function that uses Vue's reactivity and returns refs and functions for a component to use.

Call it and assert

A composable is a function. A test can call it like any other function. The refs it returns are read and written through .value in script, which includes test files, because only a template unwraps a ref for you. Destructuring the returned object is safe: those are refs, not properties of a reactive object, so they keep their identity.

When it needs a component

Some composables register lifecycle hooks, use provide or inject, or rely on an owning effect scope. Called straight from a test there is no active component instance, so the hook never runs and Vue logs a warning. The fix is a tiny helper, usually called withSetup: create an app whose setup calls the composable, mount it to a detached element, and return both the result and the app. You get the result to assert on, and the app to unmount when you want to prove cleanup happened.

Time

Anything built on setTimeout or setInterval should not make the suite wait. vi.useFakeTimers() replaces those globals, vi.advanceTimersByTime(ms) moves the clock, and vi.useRealTimers() in an afterEach puts them back so the next file is not affected.

Example

Example · vue
// src/composables/__tests__/useCounter.spec.ts
import { describe, expect, it } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('starts at the value it is given', () => {
    const { count } = useCounter(3)

    expect(count.value).toBe(3)
    // Output: 3
  })

  it('counts up and resets to the starting value', () => {
    const { count, increment, reset } = useCounter(3)

    increment()
    increment()
    expect(count.value).toBe(5)

    reset()
    expect(count.value).toBe(3)
    // Output: 3
  })

  it('refuses to go below the floor', () => {
    const { count, decrement } = useCounter(0, { min: 0 })

    decrement()
    expect(count.value).toBe(0)
  })
})

When to use it

  • Pin down a pricing or date formatter before refactoring it.
  • Check that a counter or pagination composable clamps at its limits.
  • Prove a composable removes its event listener when the component unmounts.
  • Test a debounce without making the suite sleep for 300ms.

More examples

withSetup, for a composable that needs an instance

useWindowWidth adds a resize listener in onMounted and removes it in onUnmounted. Those hooks only run inside a component, so the test wraps the composable in a throwaway app. app.unmount() is what makes the cleanup assertion possible: without it you can test the value but never the teardown. Keep this helper in your test folder and reuse it for anything using provide, inject or lifecycle hooks.

Example · ts
// src/composables/__tests__/withSetup.ts
import { createApp, type App } from 'vue'

export function withSetup<T>(composable: () => T): [T, App] {
  let result!: T
  const app = createApp({
    setup() {
      result = composable()
      // return an empty render function to silence the missing-template warning
      return () => {}
    },
  })
  app.mount(document.createElement('div'))
  return [result, app]
}

// src/composables/__tests__/useWindowWidth.spec.ts
import { expect, it, vi } from 'vitest'
import { withSetup } from './withSetup'
import { useWindowWidth } from '../useWindowWidth'

it('stops listening when the owner unmounts', () => {
  const remove = vi.spyOn(window, 'removeEventListener')
  const [width, app] = withSetup(() => useWindowWidth())

  expect(width.value).toBe(window.innerWidth)

  app.unmount()
  expect(remove).toHaveBeenCalledWith('resize', expect.any(Function), undefined)
})

Fake timers for a debounce

Two separate clocks are at work. The internal watcher runs on Vue's scheduler, which flushes in a microtask, so await nextTick() is needed before the timeout has even been scheduled. Only then does advancing the fake clock do anything. Vitest does not fake promises, so awaiting nextTick still resolves normally with fake timers active. Splitting the advance into 299 then 1 proves the boundary rather than assuming it.

Example · ts
// src/composables/__tests__/useDebounced.spec.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useDebounced } from '../useDebounced'

describe('useDebounced', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.useRealTimers()
  })

  it('publishes the last value once the delay has passed', async () => {
    const query = ref('a')
    const debounced = useDebounced(query, 300)

    query.value = 'ab'
    await nextTick()

    vi.advanceTimersByTime(299)
    expect(debounced.value).toBe('a')
    // Output: 'a'

    vi.advanceTimersByTime(1)
    expect(debounced.value).toBe('ab')
    // Output: 'ab'
  })
})

The composable being tested

The source the primary example tests. Note that nothing here touches the DOM or a lifecycle hook, which is why the test can call it directly. Returning an object of refs and functions, rather than a reactive object, is what makes const { count, increment } = useCounter() safe to destructure in both components and tests.

Example · ts
// src/composables/useCounter.ts
import { ref, type Ref } from 'vue'

interface CounterOptions {
  min?: number
  max?: number
}

interface Counter {
  count: Ref<number>
  increment: () => void
  decrement: () => void
  reset: () => void
}

export function useCounter(start = 0, options: CounterOptions = {}): Counter {
  const { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY } = options
  const count = ref(start)

  function set(value: number) {
    count.value = Math.min(max, Math.max(min, value))
  }

  return {
    count,
    increment: () => set(count.value + 1),
    decrement: () => set(count.value - 1),
    reset: () => set(start),
  }
}

Discussion

  • Be the first to comment on this lesson.