Resource library

QA How-To

Cypress Component Test Vue Composables Tutorial (2026)

Learn cypress component test vue composables with Vue 3 harnesses, reactive assertions, cleanup checks, injected dependencies, and runnable TypeScript.

18 min read | 2,294 words

TL;DR

To test a Vue composable in Cypress, mount a minimal harness component that calls the composable in setup, exposes its state through the DOM, and triggers its methods through real UI events. Inject external dependencies and assert lifecycle cleanup so tests stay deterministic.

Key Takeaways

  • Mount a small Vue harness because composables that use lifecycle hooks need an active component instance.
  • Assert rendered state and user-visible behavior instead of reaching into Vue internals.
  • Inject browser or API dependencies so each test controls inputs without global leakage.
  • Use Cypress retry-ability for asynchronous reactive changes instead of fixed waits.
  • Verify listener cleanup by unmounting the harness and checking the dependency spy.
  • Keep pure business logic separate when it can be tested without a Vue runtime.

A reliable cypress component test vue composables workflow treats a composable as behavior used by a component, not as a bag of implementation details. Mount a tiny Vue harness, exercise it through buttons or controlled browser events, and assert reactive output in the rendered DOM. This approach gives lifecycle hooks, provide/inject, and watchers the real Vue component context they require.

This tutorial builds two composables and tests the difficult parts: computed state, validation, asynchronous work, injected dependencies, window events, and cleanup. If you need a wider foundation first, read the Cypress component testing guide and the Cypress tutorial for beginners.

What You Will Build

You will create a small Vue 3 project containing:

  • useCounter, a composable with a bounded reactive count, a computed label, and an asynchronous reset.
  • useWindowWidth, a lifecycle-aware composable that subscribes to a resize source and removes its listener on unmount.
  • Two explicit harness components that translate composable state into accessible HTML.
  • Cypress component specs that cover synchronous updates, rejected inputs, Promise completion, dependency injection, and teardown.
  • A repeatable decision rule for choosing Cypress Component Testing versus a fast unit test.

The finished tests run in a real browser while Vite compiles the Vue single-file components. They remain narrow: there is no router, backend, login, or full-page navigation.

Prerequisites

Use this known-compatible baseline: Node.js 22.17.0, npm 10.9.2, Vue 3.5.17, Cypress 15.1.0, Vite 7.0.4, TypeScript 5.8.3, @vitejs/plugin-vue 6.0.1, and vue-tsc 2.2.12. Exact patch versions make the tutorial reproducible; a later compatible patch should also work.

Check Node and npm:

node --version
npm --version

Expected output starts with v22.17.0 and 10.9.2. You also need a desktop browser supported by Cypress and a terminal opened at an empty working directory. No Cypress Cloud account is required.

Step 1: Create the Vue and Cypress Component Test Project

Create the package manifest first:

{
  "name": "vue-composable-ct",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "typecheck": "vue-tsc --noEmit",
    "cy:open": "cypress open --component",
    "cy:run": "cypress run --component"
  },
  "dependencies": {
    "vue": "3.5.17"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "6.0.1",
    "cypress": "15.1.0",
    "typescript": "5.8.3",
    "vite": "7.0.4",
    "vue-tsc": "2.2.12"
  }
}

Install the locked dependencies:

npm install

Add tsconfig.json so both application and Cypress files receive DOM types:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "jsx": "preserve",
    "lib": ["ES2022", "DOM"],
    "types": ["cypress"],
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts", "src/**/*.vue", "cypress/**/*.ts", "cypress.config.ts"]
}

Configure Vite and Cypress with the same Vue compiler:

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({ plugins: [vue()] })
// cypress.config.ts
import { defineConfig } from 'cypress'

export default defineConfig({
  component: {
    devServer: {
      framework: 'vue',
      bundler: 'vite',
    },
    specPattern: 'cypress/component/**/*.cy.{ts,tsx}',
  },
})

Create cypress/support/component.ts:

import { mount } from 'cypress/vue'
import './component.css'

declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount
    }
  }
}

Cypress.Commands.add('mount', mount)

Create an empty cypress/support/component.css. Cypress supplies the official cypress/vue mount adapter, so do not install an old community mount package.

Verify Step 1: run npm run typecheck, then npm run cy:open. The Cypress launchpad should show Component Testing with Vite and Vue, without a missing preprocessor error.

Step 2: Write a Stateful Vue Composable

Create src/composables/useCounter.ts. The options object gives the test meaningful boundaries instead of a trivial increment-only example.

import { computed, ref } from 'vue'

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

export function useCounter(options: CounterOptions = {}) {
  const min = options.min ?? 0
  const max = options.max ?? 10
  const initial = options.initial ?? min

  if (min > max) throw new RangeError('min must not exceed max')
  if (initial < min || initial > max) {
    throw new RangeError('initial must be inside the configured range')
  }

  const count = ref(initial)
  const label = computed(() => `Count: ${count.value}`)
  const canIncrement = computed(() => count.value < max)
  const canDecrement = computed(() => count.value > min)

  function increment() {
    if (canIncrement.value) count.value += 1
  }

  function decrement() {
    if (canDecrement.value) count.value -= 1
  }

  async function resetAfter(delayMs: number) {
    await new Promise<void>((resolve) => window.setTimeout(resolve, delayMs))
    count.value = initial
  }

  return { count, label, canIncrement, canDecrement, increment, decrement, resetAfter }
}

Vue automatically unwraps refs returned from setup() when a template reads them. Consumers call increment() without knowing whether count is a ref, computed value, or another reactive primitive. That public boundary is what the component test should preserve.

The constructor guards are intentional. Invalid configuration is a programmer error, while clicking beyond the limit is a valid user action that changes nothing. Tests should distinguish those contracts.

Verify Step 2: run npm run typecheck. A successful command prints no TypeScript diagnostics and exits with status 0.

Step 3: Build a Vue Composable Test Harness

A composable is not mounted directly because Cypress mounts Vue components. Create src/components/CounterHarness.vue as the smallest realistic consumer:

<script setup lang="ts">
import { useCounter } from '../composables/useCounter'

const props = withDefaults(defineProps<{
  initial?: number
  min?: number
  max?: number
}>(), {
  initial: 2,
  min: 0,
  max: 3,
})

const {
  label,
  canIncrement,
  canDecrement,
  increment,
  decrement,
  resetAfter,
} = useCounter(props)
</script>

<template>
  <section aria-label="counter harness">
    <output aria-live="polite">{{ label }}</output>
    <button :disabled="!canDecrement" @click="decrement">Decrease</button>
    <button :disabled="!canIncrement" @click="increment">Increase</button>
    <button @click="resetAfter(50)">Reset later</button>
  </section>
</template>

This harness exposes state with an output, exposes commands with native buttons, and maps boolean computed values to disabled states. Those are observable contracts. Avoid defineExpose() solely for tests, because it gives tests a privileged interface that production users never exercise.

A dedicated harness is preferable to mounting a large production card when the composable is the subject. The production card may include CSS, analytics, routing, and unrelated data fetching. Those dependencies make failures ambiguous. The harness should still use semantic HTML so the spec resembles genuine usage.

Verify Step 3: run npm run typecheck. Confirm that Vue reports neither an unwrapped-ref error nor a prop type mismatch.

Step 4: Cypress Component Test Vue Composables Through the DOM

Create cypress/component/CounterHarness.cy.ts:

import CounterHarness from '../../src/components/CounterHarness.vue'

describe('useCounter through CounterHarness', () => {
  it('updates derived state and enforces the upper boundary', () => {
    cy.mount(CounterHarness, {
      props: { initial: 2, min: 0, max: 3 },
    })

    cy.contains('output', 'Count: 2')
    cy.contains('button', 'Increase').click()
    cy.contains('output', 'Count: 3')
    cy.contains('button', 'Increase').should('be.disabled')
    cy.contains('button', 'Increase').should('have.attr', 'disabled')
  })

  it('enforces the lower boundary', () => {
    cy.mount(CounterHarness, {
      props: { initial: 1, min: 0, max: 3 },
    })

    cy.contains('button', 'Decrease').click().click()
    cy.contains('output', 'Count: 0')
    cy.contains('button', 'Decrease').should('be.disabled')
  })
})

The first test proves three connected outcomes: the event calls the composable method, the ref mutation invalidates the computed label, and the boundary computed disables the control. Cypress queues each command and retries DOM assertions until they pass. You do not need nextTick() because cy.contains() waits for Vue's render cycle.

The second test deliberately clicks twice. The first click reaches zero; the disabled button prevents the second action. This is stronger than invoking decrement() directly because it checks the integration between state and user interaction. For selector strategy in larger components, use stable Cypress data-cy selectors. Semantic text is enough for this tiny harness.

Verify Step 4: run npm run cy:run -- --spec cypress/component/CounterHarness.cy.ts. Cypress should report 2 passing, and the browser console should contain no uncaught Vue errors.

Step 5: Test Errors and Asynchronous Reactive State

Add two cases to the same describe block. One controls time with Cypress's clock; the other verifies the composable's configuration guard.

  it('resets after the requested delay', () => {
    cy.clock()
    cy.mount(CounterHarness, {
      props: { initial: 1, min: 0, max: 4 },
    })

    cy.contains('button', 'Increase').click().click()
    cy.contains('output', 'Count: 3')
    cy.contains('button', 'Reset later').click()

    cy.tick(49)
    cy.contains('output', 'Count: 3')
    cy.tick(1)
    cy.contains('output', 'Count: 1')
  })

  it('surfaces invalid range configuration', () => {
    const onUncaught = cy.stub().as('vueError')
    cy.on('uncaught:exception', (error) => {
      onUncaught(error)
      return false
    })

    cy.mount(CounterHarness, {
      props: { initial: 5, min: 0, max: 3 },
    })

    cy.get('@vueError').should('have.been.calledOnce')
    cy.get('@vueError').its('firstCall.args.0.message')
      .should('equal', 'initial must be inside the configured range')
  })

cy.clock() replaces browser timer functions before the component is mounted. cy.tick(50) advances virtual time instantly, so this spec does not spend 50 real milliseconds or become sensitive to machine load. The assertion at 49 ms proves the method does not reset early.

Returning false from the exception listener is acceptable only because this test intentionally triggers the error and asserts its exact message. Do not add a global exception handler that hides application failures. If timing tests are new to your suite, the principles in Cypress retry-ability explain why retrying queries is different from sleeping.

Verify Step 5: rerun the spec command. The result should now be 4 passing. Temporarily change cy.tick(1) to cy.tick(0) and confirm the reset assertion fails, then restore it.

Step 6: Design a Lifecycle-Aware Composable with Injection

Browser globals are hard to isolate when a composable registers listeners. Define a narrow source contract in src/composables/useWindowWidth.ts and inject its implementation:

import { onMounted, onUnmounted, ref, type InjectionKey } from 'vue'

export interface WidthSource {
  read(): number
  subscribe(listener: () => void): void
  unsubscribe(listener: () => void): void
}

export const widthSourceKey: InjectionKey<WidthSource> = Symbol('widthSource')

export function createBrowserWidthSource(): WidthSource {
  return {
    read: () => window.innerWidth,
    subscribe: (listener) => window.addEventListener('resize', listener),
    unsubscribe: (listener) => window.removeEventListener('resize', listener),
  }
}

export function useWindowWidth(source: WidthSource) {
  const width = ref(source.read())
  const syncWidth = () => {
    width.value = source.read()
  }

  onMounted(() => source.subscribe(syncWidth))
  onUnmounted(() => source.unsubscribe(syncWidth))

  return { width }
}

Pass the dependency as an argument even though an injection key exists. The component owns the inject() decision, and the composable stays explicit. This makes missing-provider behavior a component concern and lets a pure unit test call the composable with a typed fake when lifecycle context is supplied.

The same syncWidth function is sent to subscribe and unsubscribe. Creating a second arrow during cleanup would fail because DOM event removal uses function identity. That subtle defect is exactly why teardown deserves a test.

Verify Step 6: run npm run typecheck. TypeScript should confirm that the symbol is a valid InjectionKey<WidthSource> and all three fakeable operations have compatible signatures.

Step 7: Mount with provide/inject and Verify Cleanup

Create src/components/WidthHarness.vue:

<script setup lang="ts">
import { inject } from 'vue'
import { useWindowWidth, widthSourceKey } from '../composables/useWindowWidth'

const source = inject(widthSourceKey)
if (!source) throw new Error('WidthSource provider is required')
const { width } = useWindowWidth(source)
</script>

<template>
  <output aria-label="viewport width">{{ width }}</output>
</template>

Now create cypress/component/WidthHarness.cy.ts. The fake stores the subscribed callback so the test controls when a resize notification occurs.

import WidthHarness from '../../src/components/WidthHarness.vue'
import { widthSourceKey, type WidthSource } from '../../src/composables/useWindowWidth'

describe('useWindowWidth through WidthHarness', () => {
  it('reacts to source notifications and unsubscribes on unmount', () => {
    let currentWidth = 1024
    let resizeListener: (() => void) | undefined

    const source: WidthSource = {
      read: cy.stub().callsFake(() => currentWidth),
      subscribe: cy.stub().callsFake((listener: () => void) => {
        resizeListener = listener
      }),
      unsubscribe: cy.stub(),
    }

    cy.mount(WidthHarness, {
      global: {
        provide: { [widthSourceKey as symbol]: source },
      },
    }).then(({ wrapper }) => {
      cy.contains('output', '1024')
      cy.wrap(source.subscribe).should('have.been.calledOnce')

      cy.then(() => {
        currentWidth = 768
        resizeListener?.()
      })
      cy.contains('output', '768')

      cy.then(() => wrapper.unmount())
      cy.wrap(source.unsubscribe).should('have.been.calledOnce')
      cy.wrap(source.unsubscribe).should('have.been.calledWith', resizeListener)
    })
  })
})

Cypress's Vue mount result includes the Vue Test Utils wrapper, whose unmount() method triggers onUnmounted. Keep wrapper access limited to teardown; state assertions still use the DOM. The spies prove subscription occurs after mount and cleanup receives the original callback.

Using a symbol key prevents unrelated providers from colliding. Vue accepts symbol properties in the global.provide object, and the cast preserves the key for the computed property. If your source calls an HTTP endpoint instead, inject a client with a narrow method and use a stub, or test the actual boundary with Cypress network stubbing.

Verify Step 7: run npm run cy:run -- --spec cypress/component/WidthHarness.cy.ts. Expect 1 passing. Comment out onUnmounted and confirm the final spy assertion fails before restoring cleanup.

Step 8: Run the Complete Cypress Component Test Vue Composables Suite

Run static type analysis before browser tests:

npm run typecheck
npm run cy:run

The final run should discover both files and report five passing tests. A headless run still launches a real browser engine; it simply does not show the interactive Cypress window. Use npm run cy:open when developing because the command log lets you inspect each mount, click, timer advance, and assertion.

Choose the test level based on the behavior:

Behavior Best first test Reason
Pure calculation with plain inputs Unit test Vue and a browser add no useful signal
Ref, computed, or watcher rendered by one component Cypress component test Proves Vue reactivity and DOM integration
onMounted or onUnmounted side effects Cypress component test Supplies a genuine component lifecycle
Router navigation across pages End-to-end test The route transition is the contract
Network formatting inside an API client Unit or contract test Keep transport behavior below the UI
Loading state driven by a request Component test with a stub Controls latency while preserving rendering

Do not force every composable into Cypress. Extract deterministic transformations into plain functions and test them cheaply. Reserve the browser for integration risks: Vue scheduling, DOM output, events, providers, timers, and lifecycle cleanup. For broader architecture choices, compare Cypress and Playwright for component testing.

Verify Step 8: both commands must exit with status 0. In CI, publish screenshots only on failure and retain the Cypress terminal output so a failing spec and assertion are visible.

Best Practices

  • Build one harness per coherent composable contract. A universal harness with switches becomes another application to maintain.
  • Name tests after outcomes, such as unsubscribes on unmount, rather than internal primitives, such as calls ref.
  • Pass props and providers through cy.mount() so every case declares its starting state.
  • Query accessible output or stable data-cy attributes. Avoid Vue-generated classes and component internals.
  • Control timers with cy.clock() and cy.tick(). Never add an arbitrary cy.wait(1000) to allow reactivity to settle.
  • Keep a reference to listener callbacks and assert the identical function is removed. A call count alone can miss incorrect cleanup.
  • Restore deliberate exception handling to the individual test. Unexpected exceptions should fail every other test.
  • Test one representative asynchronous success path at component level, then cover combinatorial error mapping closer to the service.

Troubleshooting

Problem: Cannot find module 'cypress/vue' -> confirm Cypress is installed in devDependencies, run npm install, and remove obsolete adapters such as @cypress/vue. Modern Cypress publishes the Vue mount adapter from cypress/vue.

Problem: onMounted is called when there is no active component instance -> do not invoke the lifecycle composable at spec top level. Call it inside a mounted harness component's setup or <script setup> block.

Problem: the output still shows the old ref value -> keep the assertion in Cypress's command chain. cy.contains() and .should() retry; a synchronous variable assertion outside the chain can run before Vue flushes its update.

Problem: a timer test hangs or uses real time -> call cy.clock() before cy.mount(). If the clock is installed afterward, the composable may already hold the native timer implementation.

Problem: the provider is undefined -> import the exact same widthSourceKey symbol in both component and spec. Two separately created symbols with the same description are not equal. Pass the value under mount option global.provide.

Problem: cleanup spy never runs -> explicitly call wrapper.unmount() inside the test. Cypress clears the mount between tests, but asserting before teardown means onUnmounted has not executed yet.

Interview Questions and Answers

Q: Why does a Vue composable sometimes need a harness component in Cypress?

Lifecycle APIs such as onMounted, onUnmounted, and dependency injection depend on an active Vue component instance. A harness creates that context and exposes the contract through DOM behavior. It also avoids adding test-only methods to a production component.

Q: Should a component test inspect a returned ref directly?

Prefer an assertion on rendered output or control state because that proves the ref affects a consumer correctly. Direct inspection can be useful for a low-level unit test, but it couples a Cypress spec to implementation.

Q: How do you make a composable with window listeners deterministic?

Wrap the browser operations in a typed dependency with read, subscribe, and unsubscribe methods. Inject a fake implementation, capture its listener, and trigger that listener under test control. Then unmount and verify removal with the same callback identity.

Q: When should you use cy.clock()?

Install it before mounting when the composable uses setTimeout, setInterval, or time-based scheduling. Advance time with cy.tick() and assert state on both sides of the boundary. Do not use it for Promise-only microtasks that have no timer.

Q: What belongs in a unit test instead?

Pure parsers, reducers, validators, and calculations usually belong in fast unit tests. A Cypress component test is justified when behavior depends on Vue reactivity, rendering, browser events, provide/inject, or lifecycle teardown.

Q: How do Cypress retries interact with Vue updates?

Cypress retries queries and their attached assertions until they pass or time out. After a user event mutates a ref, query the DOM again and let Cypress observe Vue's next render. Do not cache a raw DOM node before the update.

Where To Go Next

Before expanding the suite, run each spec alone and in the full set. Fresh mounts, local fakes, and explicit providers prevent order dependence. Keep mutable test data inside each case, especially when CI distributes specs across machines. A test that passes only after another test is not isolated and will eventually become flaky. Review the Cypress command log after deliberate failures so your team knows which state transitions remain visible during diagnosis.

You now have a complete pattern: create an explicit dependency boundary, consume the composable inside a focused Vue harness, drive public behavior, and verify teardown. Apply it to storage synchronization, media queries, polling, or permission state without turning the harness into a full application.

Continue with the Cypress component testing example for more mounting patterns. Strengthen asynchronous suites with Cypress handling flaky tests, then study the modern Cypress test architecture guide before organizing many component specs. You can also use the QA practice workspace to rehearse test-design decisions or upload your project summary to the resume dashboard.

Interview Questions and Answers

Why use a harness to test a Vue composable with Cypress?

A harness supplies the active component instance required by Vue lifecycle and injection APIs. It presents state through rendered HTML and actions through events, which tests the composable as a consumer uses it. Keeping the harness small also makes failures easier to localize.

How would you test cleanup performed by onUnmounted?

I would inject a subscription dependency with spies, capture the callback passed during mount, and explicitly unmount the Cypress Vue wrapper. Then I would assert that unsubscribe ran once with the identical callback. This detects leaks caused by passing a newly created function to cleanup.

Why avoid fixed waits after changing a Vue ref?

Fixed waits guess how long rendering will take and slow the suite even when the update is immediate. Cypress queries and assertions retry, so I trigger the action and query the resulting DOM state. For actual timer behavior, I control time with `cy.clock()` and `cy.tick()`.

What is the boundary between a composable unit test and a Cypress component test?

I use unit tests for pure logic and broad input combinations. I choose Cypress when the risk involves Vue rendering, lifecycle hooks, browser events, accessibility state, or provide/inject integration. The two levels complement each other instead of duplicating every case.

How do you test a composable that depends on a browser API?

I define a narrow interface around the browser API and inject it. The component test provides a deterministic fake, changes its values, and invokes captured callbacks. That prevents one spec from mutating shared globals and makes failure conditions controllable.

What should a Vue composable harness expose?

It should expose only observable behavior needed by a real consumer: text output, accessible status, enabled states, and user actions. I avoid test-only exposed refs or methods because they couple the test to implementation. Props and providers declare the starting scenario.

How does Cypress retry-ability help with Vue composable tests?

Cypress re-runs queries and attached assertions while Vue processes reactive changes and updates the DOM. I keep assertions in the Cypress chain and make a fresh query after the action. Commands that cause side effects are not blindly repeated, so the test remains predictable.

Frequently Asked Questions

Can Cypress test a Vue composable directly?

Cypress mounts Vue components, so the practical method is to call the composable inside a minimal harness component. The harness supplies lifecycle context and turns reactive state into DOM behavior that Cypress can observe.

Do I need Vue Test Utils for Cypress component tests?

Cypress's Vue adapter uses the Vue mounting ecosystem and returns a wrapper, but most assertions should go through Cypress DOM commands. Use the wrapper sparingly for operations such as explicit unmounting when you need to verify cleanup.

How do I test onMounted and onUnmounted in a composable?

Call the composable from a mounted harness, spy on its injected subscription dependency, and assert subscription after mount. Call `wrapper.unmount()` and verify that unsubscribe received the original callback.

How do I wait for Vue reactivity in Cypress?

Issue a fresh Cypress query such as `cy.contains()` or `cy.get().should()` after the action. Cypress retries the query while Vue flushes the render, so fixed delays and manual `nextTick()` calls are usually unnecessary.

Should every Vue composable have a Cypress test?

No. Test pure calculations with a unit runner, and use Cypress where a browser, Vue lifecycle, rendering, events, or providers contribute meaningful behavior. This keeps the suite fast without skipping integration risks.

How can I test a composable that reads window.innerWidth?

Place reading and listener registration behind a small `WidthSource` interface. Provide a fake source whose width and notification callback the test controls, then assert rendered width changes and listener cleanup.

Can I use TypeScript injection keys in a Cypress mount?

Yes. Export one `InjectionKey<T>` symbol, import that same symbol in the component and spec, and pass the fake through `global.provide`. Symbol identity matters, so never recreate the key in the test.

Related Guides