Mocking

Replace a child, a composable, the router, a store or fetch with something predictable, and keep the test about your component.

Syntaxvi.mock('@/composables/useMessages', () => ({ useMessages: () => ({ /* refs */ }) }))

A mock is a stand-in. You reach for one when a real dependency is slow, unavailable in jsdom, or simply not the thing under test. The goal is a test that fails only when your component is wrong.

Children

The global.stubs mounting option replaces a child component with a placeholder. stubs: ['AvatarBadge'] is the array shorthand for stubs: { AvatarBadge: true } and they do the same thing. Pass a small component instead of true when the test needs the child to render something.

Modules

vi.mock(path, factory) replaces a whole module for the file it appears in. This is how you mock a composable: return an object with the same exported names, where the refs hold the state the test wants. The same call mocks vue-router, so useRouter() hands your component a push you can assert on without a real router.

Stores

Pinia ships a test helper in a separate package, @pinia/testing. createTestingPinia() installs a Pinia whose actions are replaced by spies, so they record calls instead of running, and whose starting state you can set with initialState. Pass createSpy: vi.fn unless you have Vitest globals enabled.

The network

Never let a unit test make a request. vi.stubGlobal('fetch', vi.fn()) swaps the global for one you control, and vi.unstubAllGlobals() puts it back.

Example

Example · vue
// src/components/__tests__/InboxPanel.spec.ts
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { mount } from '@vue/test-utils'
import InboxPanel from '../InboxPanel.vue'

vi.mock('@/composables/useMessages', () => ({
  useMessages: () => ({
    messages: ref([
      { id: 1, subject: 'Invoice' },
      { id: 2, subject: 'Receipt' },
    ]),
    pending: ref(false),
    error: ref<Error | null>(null),
  }),
}))

describe('InboxPanel', () => {
  it('renders one row per message the composable returns', () => {
    const wrapper = mount(InboxPanel, {
      global: { stubs: { MessageRow: true } },
    })

    expect(wrapper.findAll('message-row-stub')).toHaveLength(2)
    expect(wrapper.get('[data-testid="count"]').text()).toBe('2 messages')
  })
})

When to use it

  • Test a page component without the API call its data composable makes.
  • Assert a component asked the router to navigate, without mounting the whole route tree.
  • Check a component dispatches a store action without running it.
  • Keep a child that renders a map or a chart out of a unit test.

More examples

Faking the router

Mocking the vue-router module gives the component a push it does not actually navigate with, which is usually what a unit test wants: the assertion is that the component asked to go somewhere. Because the factory replaces the whole module, every export the component uses has to appear in it, which is why useRoute is there too. When you do want real navigation, mount with global.plugins: [router] using createRouter and createMemoryHistory, then await router.isReady() before asserting.

Example · ts
// src/components/__tests__/AccountMenu.spec.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { useRouter } from 'vue-router'
import AccountMenu from '../AccountMenu.vue'

vi.mock('vue-router', () => ({
  useRouter: vi.fn(),
  useRoute: vi.fn(() => ({ params: {}, query: {} })),
}))

describe('AccountMenu', () => {
  const push = vi.fn()

  beforeEach(() => {
    push.mockClear()
    vi.mocked(useRouter).mockReturnValue(
      { push } as unknown as ReturnType<typeof useRouter>,
    )
  })

  it('navigates to billing when that item is chosen', async () => {
    const wrapper = mount(AccountMenu, { props: { email: '[email protected]' } })

    await wrapper.get('[data-testid="billing"]').trigger('click')

    expect(push).toHaveBeenCalledWith({ name: 'billing' })
  })
})

A store with spied actions

createTestingPinia stubs every action by default, so cart.clear records the call and the state is left alone, which is why items still has one entry at the end. initialState is keyed by store id, so cart here matches defineStore('cart', ...). Pass stubActions: false when the test is about what the action actually does. Install the helper separately: npm install -D @pinia/testing.

Example · ts
// src/components/__tests__/CartSummary.spec.ts
import { expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useCartStore } from '@/stores/cart'
import CartSummary from '../CartSummary.vue'

it('shows the count and asks the store to clear', async () => {
  const wrapper = mount(CartSummary, {
    global: {
      plugins: [
        createTestingPinia({
          createSpy: vi.fn,
          initialState: {
            cart: { items: [{ id: 1, quantity: 2 }] },
          },
        }),
      ],
    },
  })

  const cart = useCartStore()

  expect(wrapper.text()).toContain('1 item')

  await wrapper.get('[data-testid="clear"]').trigger('click')

  expect(cart.clear).toHaveBeenCalledTimes(1)
  expect(cart.items).toHaveLength(1)
})

A fake fetch

Returning a real Response object means the composable's own res.ok and res.json() calls behave normally, so you are testing your code rather than a hand-rolled fake. Holding the promise lets the test assert the pending flag before resolution, which is the part users actually see. flushPromises, exported by @vue/test-utils, drains anything queued after the await. For a whole suite of endpoints a request-interception library such as MSW scales better than stubbing fetch per test.

Example · ts
// src/composables/__tests__/useOrders.spec.ts
import { afterEach, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useOrders } from '../useOrders'

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

it('exposes the orders the endpoint returned', async () => {
  const body = JSON.stringify({ orders: [{ id: 'a1', total: 20 }] })
  const fetchMock = vi.fn(() =>
    Promise.resolve(
      new Response(body, {
        status: 200,
        headers: { 'content-type': 'application/json' },
      }),
    ),
  )
  vi.stubGlobal('fetch', fetchMock)

  const { orders, pending, load } = useOrders()
  const done = load()

  expect(pending.value).toBe(true)
  await done
  await flushPromises()

  expect(fetchMock).toHaveBeenCalledWith('/api/orders', expect.anything())
  expect(orders.value).toEqual([{ id: 'a1', total: 20 }])
  // Output: [ { id: 'a1', total: 20 } ]
})

Discussion

  • Be the first to comment on this lesson.